Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ research/
.env
.env.*
.codex/

# Doctor staleness-check cache (per-install, transient)
.pixelslop-doctor-cache.json
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>] [--last <n>]` 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 '<persona>'` validates (required fields, slug-only id, no built-in collision, no path traversal) and saves to `.pixelslop/personas/<id>.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

Expand Down
87 changes: 87 additions & 0 deletions bin/pixelslop-tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -3367,6 +3451,9 @@ async function main() {
}

switch (group) {
case 'doctor':
return doctorCheck(flags);

case 'plan':
switch (command) {
case 'begin': return planBegin(flags);
Expand Down
16 changes: 16 additions & 0 deletions dist/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions tests/doctor.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
9 changes: 9 additions & 0 deletions tests/skill-discoverability.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading