/* Does the documentation still match the code? ── Why a script and not a habit ── "Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process list — so it is checkable, and a check that exits non-zero cannot be quietly not done. What it will not do is read prose and judge it. It looks only at claims with a machine-checkable counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a number is exactly the thing nobody re-derives while editing a sentence around it. Real examples this was written from, both live at the time: platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18 officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13 Spotlight's settings list 9 rows against 21 sections, and one route that does not exist ── The rule for adding a check ── A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in the same edit that misses the doc. */ import { readFileSync, existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; type Finding = { file: string; claim: string; documented: string; actual: string }; type CheckResult = { findings: Finding[]; skipped?: string }; const ROOT = resolve(import.meta.dir, '..'); const WORKSPACE = resolve(ROOT, '..'); const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null); /** Spelled-out numbers appear in prose more often than digits, and both need catching. */ const WORDS: Record = { ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, 'twenty-one': 21, 'twenty-two': 22, 'twenty-three': 23, 'twenty-four': 24, 'twenty-five': 25, }; const asNumber = (raw: string): number | null => { const digits = Number(raw); if (Number.isFinite(digits)) return digits; return WORDS[raw.toLowerCase()] ?? null; }; /** * A documented count, found by a pattern with one capture group, compared to the live value. * * Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine. */ function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult { const body = read(file); if (!body) return { findings: [], skipped: `${file} not found` }; const findings: Finding[] = []; for (const match of body.matchAll(pattern)) { const stated = asNumber(match[1] ?? ''); if (stated === null || stated === actual) continue; findings.push({ file, claim, documented: String(stated), actual: String(actual) }); } return { findings }; } async function main(): Promise { const all: Finding[] = []; const skipped: string[] = []; const record = (r: CheckResult) => { all.push(...r.findings); if (r.skipped) skipped.push(r.skipped); }; // 1. How many permissions the registry holds. The number appears in prose and is never recomputed. const { PERMISSIONS } = await import('../src/servers/permissions/registry'); record( checkCount( join(ROOT, 'AGENTS.md'), /([0-9]+|[a-z-]+) entries as of/gi, PERMISSIONS.length, 'permission registry entries', ), ); // 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an // admission that it needs a check rather than a caveat. const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' }); if (pm2.exitCode === 0) { try { const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>; const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length; record( checkCount( join(WORKSPACE, 'CLAUDE.md'), /Officer is ([a-z-]+|[0-9]+) PM2 entries/gi, officer, 'officer PM2 entries', ), ); } catch { skipped.push('pm2 jlist did not parse'); } } else { // Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point. skipped.push('pm2 unavailable — PM2 entry count unchecked'); } // 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped // a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count. const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings'); const declared = await SPOTLIGHT_SETTINGS_PATHS(); for (const bad of declared.missing) { all.push({ file: 'Spotlight/useSpotlightItems.ts', claim: 'settings route offered by Spotlight', documented: bad, actual: 'no screen declares that section', }); } // 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc: // an agent quotes it to a user with no way to tell it is out of date. const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill'); const expected = await renderPlatformSkill(); const onDisk = read(SKILL_PATH); if (onDisk === null) { all.push({ file: 'capabilities/skills/platform/SKILL.md', claim: 'generated platform skill', documented: 'missing', actual: 'run `bun run skills:platform`', }); } else if (onDisk !== expected) { all.push({ file: 'capabilities/skills/platform/SKILL.md', claim: 'generated platform skill is stale', documented: `${onDisk.split('\n').length} lines on disk`, actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``, }); } for (const note of skipped) console.log(` skipped: ${note}`); if (all.length === 0) { console.log('docs:check — every checkable claim matches the code'); return; } console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`); for (const f of all) { console.log(` ${f.file}`); console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`); } process.exit(1); } await main();