diff --git a/.gitignore b/.gitignore index 60a5122..3d805f0 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ research/ .env .env.* .codex/ + +# Doctor staleness-check cache (per-install, transient) +.pixelslop-doctor-cache.json diff --git a/CLAUDE.md b/CLAUDE.md index b89a2d9..ada5242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,7 @@ Agents use `pixelslop-tools` (bin/pixelslop-tools.cjs) for all state operations. - **`config read-tokens` / `config write-tokens`** read and write the project's normative design tokens — a `## Design Tokens` section in `.pixelslop.md` holding flat `key: value` lines (`color-primary: #b8422e`, `font-body: Inter`, `type-scale: 1.25`, `space-unit: 4px`). The setup agent captures them from the codebase; the fixer reads them so a fix moves *toward* the project's real palette/type/spacing instead of a generic default. `write-tokens` merges (unspecified keys preserved) and only touches the Design Tokens section — `config write` stays the initializer, tokens layer on top like settings do. - **`scan trend`** reports the score progression across runs. `scan save-results` now appends each run's /20 total (plus per-pillar scores) to `.pixelslop/scan-history.json`; `scan trend [--target ] [--last ]` reads it back (`11 -> 13 -> 14 (+3)`). History is best-effort — a corrupt history file self-heals and never blocks the actual save. The orchestrator surfaces the trend in its scan summary. - **`personas write` / `personas list`** manage project-specific personas. `write --json ''` validates (required fields, slug-only id, no built-in collision, no path traversal) and saves to `.pixelslop/personas/.json`; `list` returns the 8 built-ins plus any custom ones. The orchestrator generates 1-2 personas from the project's audience/brand and evaluates them alongside the built-ins, so persona findings fit the real users instead of only the generic profiles. +- **`doctor`** self-checks the install: reports the version, confirms `pixelslop-tools.cjs` is reachable, and flags when a newer version is published (a throttled npm check, cached 24h in `.pixelslop-doctor-cache.json`, fail-soft offline). The skill runs it at preflight — if it can't run, the install is broken/stale and the skill tells the user to `npx pixelslop@latest update`; if it reports `stale`, the skill surfaces the update once and continues. This is what makes a stale/broken install self-diagnose instead of failing opaquely. Inspired by screenslop's doctor-in-the-skill pattern. ## Voice & Persona diff --git a/bin/pixelslop-tools.cjs b/bin/pixelslop-tools.cjs index de42af4..46ee41d 100644 --- a/bin/pixelslop-tools.cjs +++ b/bin/pixelslop-tools.cjs @@ -2975,6 +2975,90 @@ function scanTrend(flags) { : 'No scan history yet.')); } +/** Compare two dotted versions; true if a is strictly older than b. */ +function versionLt(a, b) { + const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0); + const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) < (pb[i] || 0)) return true; + if ((pa[i] || 0) > (pb[i] || 0)) return false; + } + return false; +} + +/** + * Resolve the installed version + root. Works in both layouts: an installed + * tree (install-manifest.json next to bin/) and the dev repo (package.json). + * @returns {{ version: string|null, installRoot: string, source: string|null }} + */ +function resolveToolVersion() { + const installRoot = path.dirname(__dirname); // bin/ -> install root (or repo root) + const manifestPath = path.join(installRoot, 'install-manifest.json'); + if (fs.existsSync(manifestPath)) { + try { + const m = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if (m.version) return { version: String(m.version), installRoot, source: 'manifest' }; + } catch { /* fall through */ } + } + const pkgPath = path.join(installRoot, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const p = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (p.name === 'pixelslop' && p.version) return { version: String(p.version), installRoot, source: 'package' }; + } catch { /* fall through */ } + } + return { version: null, installRoot, source: null }; +} + +/** + * The latest published version, throttled to one network call per 24h (cached + * in the install root). Fail-soft: returns null if offline or npm is slow, so a + * scan is never blocked by a flaky network. + */ +function resolveLatestVersion(installRoot, offline) { + if (offline) return { latest: null, source: 'offline' }; + const cachePath = path.join(installRoot, '.pixelslop-doctor-cache.json'); + try { + if (fs.existsSync(cachePath)) { + const c = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + if (c.checkedAt && Date.now() - new Date(c.checkedAt).getTime() < 24 * 3600 * 1000) { + return { latest: c.latest || null, source: 'cache' }; + } + } + } catch { /* ignore cache errors */ } + let latest = null; + try { + latest = execFileSync('npm', ['view', 'pixelslop', 'version'], { + encoding: 'utf-8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch { latest = null; } + if (latest) { + try { fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: new Date().toISOString(), latest })); } catch { /* best effort */ } + } + return { latest, source: latest ? 'npm' : 'unreachable' }; +} + +/** + * Self-check the install: confirm this tool is reachable, report the version, + * and (throttled) flag if a newer version is published. The skill runs this at + * preflight so a stale or broken install self-diagnoses instead of failing + * opaquely. Flags: --offline (skip the network check), --raw. + */ +function doctorCheck(flags = {}) { + const { version, installRoot, source } = resolveToolVersion(); + const { latest, source: latestSource } = resolveLatestVersion(installRoot, flags.offline); + const stale = !!(version && latest && versionLt(version, latest)); + + const status = stale ? 'stale' : 'ok'; + const message = stale + ? `pixelslop ${version} is behind the latest ${latest}. Update with: npx pixelslop@latest update` + : `pixelslop ${version || '(unknown version)'} — install reachable and current`; + + output(RAW + ? { ok: true, status, version, latest, stale, reachable: true, installRoot, toolPath: __filename, versionSource: source, latestSource } + : message); +} + /** * Generate a self-contained HTML report from scan results. * Fail-soft: returns { ok: false, error } on any failure. @@ -3367,6 +3451,9 @@ async function main() { } switch (group) { + case 'doctor': + return doctorCheck(flags); + case 'plan': switch (command) { case 'begin': return planBegin(flags); diff --git a/dist/skill/SKILL.md b/dist/skill/SKILL.md index 916ee39..2250d1c 100644 --- a/dist/skill/SKILL.md +++ b/dist/skill/SKILL.md @@ -326,6 +326,22 @@ If the user confirms starting a dev server, run the detected start command and w ## Phase 2: Pre-flight Check +### Install self-check (do this first) + +Before anything else, confirm the pixelslop install is healthy and current: + +```bash +node bin/pixelslop-tools.cjs doctor --raw +``` + +- **If this command fails to run** (command not found, file missing, non-zero exit) the install is broken or incomplete — almost always a stale or partial install. Stop and tell the user: *"Your pixelslop install looks broken or incomplete. Run `npx pixelslop@latest update`, then try again."* Don't attempt the scan. +- **If it returns `"stale": true`** a newer version is published. Tell the user once: *"A newer pixelslop is available — run `npx pixelslop@latest update` for the latest fixes. Continuing with the installed version."* Then proceed (don't block). +- **If it returns `"status": "ok"`** proceed silently. + +This is what makes a stale or broken install self-diagnose instead of failing opaquely mid-scan. `doctor` only checks the network once per day (cached), so it's cheap to run every time. + +### Validate the environment + Run init to validate the environment: ```bash diff --git a/tests/doctor.test.js b/tests/doctor.test.js new file mode 100644 index 0000000..657720b --- /dev/null +++ b/tests/doctor.test.js @@ -0,0 +1,73 @@ +/** + * Doctor Self-Check Tests + * + * `pixelslop-tools doctor` is what the skill runs at preflight so a stale or + * broken install self-diagnoses instead of failing opaquely (the RankShaker + * "tools.cjs not installed" confusion). It reports the install version, confirms + * the tool is reachable, and flags when a newer version is published. + * + * Stale detection is tested by seeding the throttle cache, so no network is + * touched and the test is deterministic. + * + * Run: node --test tests/doctor.test.js + */ + +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const TOOLS = join(ROOT, 'bin', 'pixelslop-tools.cjs'); +const CACHE = join(ROOT, '.pixelslop-doctor-cache.json'); // installRoot === repo root when run from here +const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version; + +function doctor(args = []) { + const out = execFileSync('node', [TOOLS, 'doctor', ...args], { encoding: 'utf-8' }); + try { return JSON.parse(out); } catch { return { _raw: out.trim() }; } +} +const seedCache = (latest) => writeFileSync(CACHE, JSON.stringify({ checkedAt: new Date().toISOString(), latest })); + +describe('pixelslop-tools doctor', () => { + afterEach(() => { if (existsSync(CACHE)) rmSync(CACHE); }); + + it('reports the install version and reachability (offline, no network)', () => { + const r = doctor(['--offline', '--raw']); + assert.equal(r.ok, true); + assert.equal(r.reachable, true); + assert.equal(r.version, VERSION, 'version comes from package.json in repo context'); + assert.equal(r.stale, false, 'no latest known offline, so not stale'); + assert.equal(r.latestSource, 'offline'); + assert.ok(r.toolPath.endsWith('pixelslop-tools.cjs'), 'reports its own path'); + }); + + it('flags stale and prints the update command when a newer version is published', () => { + seedCache('99.0.0'); + const r = doctor(['--raw']); + assert.equal(r.stale, true, 'current version is behind the seeded latest'); + assert.equal(r.latest, '99.0.0'); + const human = doctor([])._raw; + assert.match(human, /behind the latest 99\.0\.0/); + assert.match(human, /npx pixelslop@latest update/, 'tells the user exactly how to fix it'); + }); + + it('is not stale when the published version equals the install', () => { + seedCache(VERSION); + const r = doctor(['--raw']); + assert.equal(r.stale, false); + }); + + it('is not stale when the install is ahead of npm (dev build)', () => { + seedCache('0.0.1'); + const r = doctor(['--raw']); + assert.equal(r.stale, false, 'an install ahead of npm is fine, not stale'); + }); + + it('uses the cache, not the network, when the cache is fresh', () => { + seedCache('42.0.0'); + // No --offline, but cache is <24h old, so it must use the cached value, not npm. + assert.equal(doctor(['--raw']).latest, '42.0.0'); + }); +}); diff --git a/tests/skill-discoverability.test.js b/tests/skill-discoverability.test.js index 1b7664a..701d970 100644 --- a/tests/skill-discoverability.test.js +++ b/tests/skill-discoverability.test.js @@ -96,6 +96,15 @@ describe('the asking protocol is harness-neutral (works under Codex too)', () => }); }); +describe('the preflight runs a doctor self-check', () => { + it('SKILL.md runs doctor and handles broken/stale installs', () => { + assert.ok(/doctor/i.test(SKILL), 'SKILL.md must run the doctor self-check at preflight'); + assert.ok(/broken or incomplete|broken or stale/i.test(SKILL), 'must handle a broken install'); + assert.ok(/stale/i.test(SKILL) && /npx pixelslop@latest update/i.test(SKILL), + 'must tell the user how to update when stale/broken'); + }); +}); + describe('the spawn protocol is harness-neutral with an inline fallback', () => { it('has a "Spawning agents" protocol', () => { assert.ok(/## Spawning agents/i.test(SKILL),