diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcd8743..9183086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: run: | node checks/check.test.mjs node checks/check-code.test.mjs + node checks/check-config.test.mjs node checks/check-trace.test.mjs node checks/check-stack.test.mjs node checks/progress.test.mjs diff --git a/.gitignore b/.gitignore index 516269b..25f7d28 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,36 @@ dist/ build/ coverage/ +# The design method (impeccable) is installed per project at its current release, never +# vendored: `node checks/design-method.mjs --install` puts it here, `maintain` refreshes it. +.agents/skills/impeccable/ + +# The block below is impeccable's own, copied from its README (v3.5.0, "Keeping .impeccable out +# of git"). Its working files are ephemeral; the shared artifacts it names stay tracked, which is +# why .impeccable/ is not ignored wholesale. +# impeccable-ignore-start +# Ephemeral output, runtime state, and per-dev overrides. +# Unanchored: .impeccable may sit at the repo root or under a nested +# workspace (apps/web/.impeccable/...); anchored patterns would miss it. +# Shared artifacts stay tracked: config.json, live/config.json, +# design.json, critique/*.md. +.impeccable/config.local.json +.impeccable/hook.cache.json +.impeccable/hook.pending.json +.impeccable/*.png +.impeccable/live/server.json +.impeccable/live/sessions/ +.impeccable/live/previews/ +.impeccable/live/annotations/ +.impeccable/live/cache/ +.impeccable/live/manual-edit-apply-transaction.json +.impeccable/live/manual-edit-events.jsonl +.impeccable/live/manual-edit-evidence/ +.impeccable/live/pending-manual-edits.json +.impeccable/live/deferred-svelte-component-accepts.json +.impeccable/live/*.png +# impeccable-ignore-end + # Secrets: never in the repo, no exceptions .env .env.* diff --git a/checks/check-code.mjs b/checks/check-code.mjs index c17e015..2af2280 100644 --- a/checks/check-code.mjs +++ b/checks/check-code.mjs @@ -100,7 +100,8 @@ export const codeChecks = ({ root, cfg, tree, textFiles, isVendored, fail, lines const looksLikeCode = /^\s*(\/\/|#)\s*(.*[;{}]\s*$|(const|let|var|function|def |import |return |if\s*\(|for\s*\())/; for (const f of tree.files.filter((x) => CODE_EXT.has(extname(x)))) { const r = rel(root, f); - if (r.startsWith('checks/')) continue; + // What another project left in its own comments is not this project's discipline to keep. + if (r.startsWith('checks/') || isVendored(r)) continue; let run = 0; lines(f).forEach((line, i) => { run = looksLikeCode.test(line) ? run + 1 : 0; @@ -122,7 +123,7 @@ export const codeChecks = ({ root, cfg, tree, textFiles, isVendored, fail, lines const cap = cfg.budgets.codeFileMaxLines ?? 500; for (const f of tree.files.filter((x) => CODE_EXT.has(extname(x)))) { const r = rel(root, f); - if ((cfg.codeFileCapExclude || []).some((x) => r.startsWith(x) || r.endsWith(x))) continue; + if (isVendored(r)) continue; const content = lines(f); if (content.length <= cap) continue; const marker = content.map(commentOn).find((c) => c !== null && /^\s*checks:allow-length\b/.test(c)); diff --git a/checks/check-code.test.mjs b/checks/check-code.test.mjs index f7251ca..8aea34d 100644 --- a/checks/check-code.test.mjs +++ b/checks/check-code.test.mjs @@ -138,4 +138,19 @@ expectClean('code-file-cap-exclude', ({ put }) => { put('src/vendor/lib.js', 'export const x = 1;\n'.repeat(510)); }); +// A declared third-party payload is code this project did not write: neither its length budget +// nor its comment discipline is ours to enforce. The declaration is the only difference between +// this fixture and the two failing ones above, and the secrets gate still reads every line of it. +expectClean('code-gates-skip-a-declared-payload', ({ put }) => { + put('checks/config.json', JSON.stringify({ + denylist: [], + budgets: { agentsMdLines: 150, stateMdLines: 150, skillMdLines: 500, skillDescriptionChars: 1024 }, + allowedEmptyDirs: [], secretScanExclude: ['checks/'], + thirdParty: [{ path: 'vendor/upstream/', why: 'installed at its current release, not written here' }], + })); + put('vendor/upstream/big.js', 'export const x = 1;\n'.repeat(510)); + put('vendor/upstream/dead.js', 'export const x = 1;\n// const old = 2;\n// function dead() {\n// return old;\n'); + put('vendor/upstream/apology.js', '// patched for now\n'); +}); + report('code-gate'); diff --git a/checks/check-config.mjs b/checks/check-config.mjs new file mode 100644 index 0000000..2afe2f4 --- /dev/null +++ b/checks/check-config.mjs @@ -0,0 +1,93 @@ +// What checks/config.json means, and the gate that keeps it honest. The config is the one file +// that can weaken every other gate, in the same commit as the violation it hides, so it gates +// itself here. This file also owns the declaration the rest of the checks read: which paths this +// project did not write. Composed into the registry by check.mjs, like the other gate families. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// Paths a project declares as somebody else's work: an installed methodology, a vendored SDK. +// One list, in the open, so the exemption is a declaration and never a silent skip. Matching is +// by prefix, which survives an upstream rename of anything inside the payload. +export function thirdPartyMatcher(cfg) { + const prefixes = ((cfg && cfg.thirdParty) || []) + .map((e) => (e && typeof e.path === 'string' ? e.path : '')) + .filter(Boolean); + return (r) => prefixes.some((p) => r === p.replace(/\/+$/, '') || r.startsWith(p)); +} + +// The same matcher for a reader that has the repo root rather than the parsed config (the +// document walk in links.mjs). A config that cannot be read declares nothing: the gates then +// measure everything, which is the safe direction to fail in. +export function thirdPartyForRoot(root) { + try { + return thirdPartyMatcher(JSON.parse(readFileSync(join(root, 'checks', 'config.json'), 'utf8'))); + } catch { + return () => false; + } +} + +export const configChecks = ({ cfg, fail }) => ({ + 'config-invariants'() { + // Both invariants come from a rule written down elsewhere, never from taste. + const cap = cfg.budgets?.agentFileHardCapLines; + // 200 is not a preference: past it an agent rulebook stops being read in full, so a higher + // cap does not buy a longer file, it buys a file that silently stops governing. The two + // ways to break it read differently, so they are reported differently. + if (cap !== undefined && !(Number.isInteger(cap) && cap > 0)) { + fail(`checks/config.json budgets.agentFileHardCapLines is ${JSON.stringify(cap)}, which is not a positive whole number of lines: agent-file-cap would fall back to 200 and the value would govern nothing.`); + } else if (cap !== undefined && cap > 200) { + fail(`checks/config.json budgets.agentFileHardCapLines is ${cap}: the hard cap is 200 lines and may be lowered, never raised. A rulebook past 200 lines stops being loaded in full.`); + } + // A boolean that retires a whole check is the same weakening vector as an exclusion that + // hides a path, and no reading of the config can tell the legitimate case (a checkout with + // no symlink support) from a gate somebody found inconvenient. So the exemption states its + // case in the same diff that takes it, the trade this repo already made for + // "checks:allow-length: " and "checks:allow-style". skills-symlink still reads the + // key as a plain flag: which value is honest is this gate's question, and one red is enough. + const skip = cfg.skipSymlinkCheck; + if (skip !== undefined && skip !== false && !(typeof skip === 'string' && skip.trim())) { + fail(`checks/config.json skipSymlinkCheck is ${JSON.stringify(skip)}: retiring the skills-symlink check takes a reason in the same file, as a non-empty string (e.g. "Windows without Developer Mode"). Set false to keep the check on.`); + } + // A third-party declaration stops several gates from measuring a path, so it states its case + // in the entry, like every other exemption here. The path itself is bounded below, by the + // same protected prefixes: a payload declared over checks/ would retire the gates wholesale. + ((cfg.thirdParty) || []).forEach((e, i) => { + if (!(e && typeof e.why === 'string' && e.why.trim())) { + fail(`checks/config.json thirdParty[${i}] has no "why": a path this project's gates stop measuring says in the same entry whose work it is and why it is not measured.`); + } + }); + // An exclusion that reaches these prefixes disarms the gates rather than tuning them: + // checks/ is where the gates themselves live, docs/standards/ is where a stack's rules do. + // secretScanExclude names checks/ by construction (the detector patterns are in check.mjs + // and would match themselves), so it is the one list allowed to, and only for that prefix. + const protectedPrefixes = ['checks/', 'docs/standards/']; + // Each list is read back by its own matching rule, so the invariant has to test the rule + // that will actually run, or it guarantees less than its message claims. An affix list + // (code-file-cap, secrets, third-party) hides a path when either end of it matches; a + // substring list (denylist) hides it when the value appears anywhere inside it. Testing only + // the prefix would let "s/" and "heck" walk past a gate whose whole job is to stop that. + const hides = (mode, v, p) => v === '' || v.startsWith(p) + || (mode === 'affix' ? p.startsWith(v) || p.endsWith(v) : p.includes(v)); + const lists = [ + ['codeFileCapExclude', cfg.codeFileCapExclude || [], protectedPrefixes, 'affix'], + ['secretScanExclude', cfg.secretScanExclude || [], ['docs/standards/'], 'affix'], + ...(cfg.thirdParty || []).map((e, i) => [`thirdParty[${i}].path`, [e?.path], protectedPrefixes, 'affix']), + ...(cfg.denylist || []).map((e, i) => [`denylist[${i}].exclude`, e.exclude || [], protectedPrefixes, 'substring']), + ]; + for (const [where, values, guarded, mode] of lists) { + for (const v of values) { + if (typeof v !== 'string') { + fail(`checks/config.json ${where} holds ${JSON.stringify(v)}: an exclusion is a path string, and a non-string silently excludes nothing.`); + continue; + } + // Overlap in either direction is a hit: "docs/" swallows docs/standards/ from above, + // "docs/standards/react.md" carves it out from within, "" swallows everything. + const hit = guarded.find((p) => hides(mode, v, p)); + if (hit) { + fail(`checks/config.json ${where} excludes "${v}", which hides ${hit}: that is where the gates (or a stack's standards) live, so excluding it disarms a check instead of tuning it. Narrow the exclusion.`); + } + } + } + }, +}); diff --git a/checks/check-config.test.mjs b/checks/check-config.test.mjs new file mode 100644 index 0000000..5cd4481 --- /dev/null +++ b/checks/check-config.test.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Self-test for checks/check-config.mjs: the config's self-gate, and the third-party declaration +// it owns. The config is the one file that can weaken every other gate, so both directions matter +// here more than anywhere: it must fail on a weakening, and it must stay quiet on a legitimate +// tune. Run: node checks/check-config.test.mjs + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import { thirdPartyMatcher } from './check-config.mjs'; +import { PAYLOAD_PATH } from './design-method.mjs'; +import { + expectClean, expectFail, withConfig, BASE_BUDGETS, tally, report, +} from './check-fixture.mjs'; + +// config-invariants: the config may be tuned, never disarmed. +expectFail('config-invariants', withConfig({ budgets: { ...BASE_BUDGETS, agentFileHardCapLines: 400 } })); +expectFail('config-invariants', withConfig({ budgets: { ...BASE_BUDGETS, agentFileHardCapLines: '200' } })); +expectFail('config-invariants', withConfig({ codeFileCapExclude: ['checks/'] })); +expectFail('config-invariants', withConfig({ codeFileCapExclude: ['docs/'] })); // swallows docs/standards/ +expectFail('config-invariants', withConfig({ codeFileCapExclude: [''] })); // swallows everything +expectFail('config-invariants', withConfig({ codeFileCapExclude: [123] })); +expectFail('config-invariants', withConfig({ secretScanExclude: ['checks/', 'docs/standards/'] })); +// The evasions a prefix-only invariant would wave through: code-file-cap also matches a +// suffix, and a denylist exclude matches a substring anywhere in the path. +expectFail('config-invariants', withConfig({ codeFileCapExclude: ['s/'] })); +expectFail('config-invariants', withConfig({ + denylist: [{ pattern: 'no-such-text-anywhere', why: 'x', exclude: ['heck'] }], +})); +expectFail('config-invariants', withConfig({ + denylist: [{ pattern: 'no-such-text-anywhere', why: 'x', exclude: ['checks/'] }], +})); +// A boolean that retires the whole skills-symlink check is the same weakening vector as an +// exclusion that hides a path, and nothing in the config tells the legitimate case (no symlink +// support) from the illegitimate one. So the exemption states its case, as allow-length does. +expectFail('config-invariants', withConfig({ skipSymlinkCheck: true })); +expectFail('config-invariants', withConfig({ skipSymlinkCheck: ' ' })); +expectFail('config-invariants', withConfig({ skipSymlinkCheck: 1 })); +// Lowering the cap is allowed; only raising it is a weakening. And the shipped secretScanExclude +// names checks/ by construction, which the clean fixture in check.test.mjs proves stays green. +expectClean('config-invariants-allows-a-lower-cap', withConfig({ + budgets: { ...BASE_BUDGETS, agentFileHardCapLines: 120 }, +})); +expectClean('config-invariants-allows-a-stated-reason', withConfig({ + skipSymlinkCheck: 'Windows without Developer Mode', +})); + +// The third-party declaration stops several gates from measuring a path, so it is bounded the +// same way every other exclusion here is: it states its reason, and it may not reach the +// directories where the gates or a stack's standards live. +expectClean('third-party-declares-a-payload', withConfig({ + thirdParty: [{ path: 'vendor/upstream/', why: 'installed at its current release, not written here' }], +})); +expectFail('config-invariants', withConfig({ + thirdParty: [{ path: 'vendor/upstream/' }], // no reason given +})); +expectFail('config-invariants', withConfig({ + thirdParty: [{ path: 'vendor/upstream/', why: ' ' }], +})); +expectFail('config-invariants', withConfig({ thirdParty: [{ why: 'no path at all' }] })); +expectFail('config-invariants', withConfig({ + thirdParty: [{ path: 'docs/', why: 'swallows docs/standards/' }], +})); +expectFail('config-invariants', withConfig({ + thirdParty: [{ path: 'checks/', why: 'would retire the gates wholesale' }], +})); +expectFail('config-invariants', withConfig({ + thirdParty: [{ path: '', why: 'swallows the whole repo' }], +})); + +{ // The matcher is a path prefix, so an upstream rename inside the payload changes nothing, and + // a sibling directory whose name merely starts the same way is not swallowed. + const third = thirdPartyMatcher({ thirdParty: [{ path: 'vendor/upstream/', why: 'x' }] }); + try { + assert.ok(third('vendor/upstream/deep/inside/file.mjs'), 'a file inside the payload is third-party'); + assert.ok(third('vendor/upstream'), 'the payload directory itself is third-party'); + assert.ok(!third('vendor/upstream-fork/file.mjs'), 'a sibling directory is not the payload'); + assert.ok(!third('checks/check.mjs'), 'this project\'s own code is never third-party'); + assert.ok(!thirdPartyMatcher({})('anything'), 'no declaration means nothing is exempt'); + tally.passed++; + } catch (e) { tally.failed.push(`third-party-matcher: ${e.message}`); } +} + +{ // Two files name the payload: the install route puts it there, the config declares it. If they + // ever disagree the gates measure a path nothing installs, silently, so the drift is a test. + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const cfg = JSON.parse(readFileSync(join(root, 'checks', 'config.json'), 'utf8')); + try { + assert.ok(thirdPartyMatcher(cfg)(PAYLOAD_PATH), + `checks/config.json must declare ${PAYLOAD_PATH} as third-party: that is where the install route puts the design method.`); + tally.passed++; + } catch (e) { tally.failed.push(`third-party-declares-the-design-method: ${e.message}`); } +} + +report('config-gate'); diff --git a/checks/check.mjs b/checks/check.mjs index 68305d7..47479be 100644 --- a/checks/check.mjs +++ b/checks/check.mjs @@ -18,11 +18,13 @@ import { parseBrief, parseManifest, isSpecPath, BRIEF_PATH, MANIFEST_PATH } from // read here and by the board. import { parseLinks, linkTargets, readDocuments, forTerminal, SKIP_DIRS } from './links.mjs'; import { enforcementReport, formatReport } from './enforcement.mjs'; -// Two gate families live in their own files, composed into the registry below: what a source -// file may contain and how long it may be, and the trace chain from brief to commit. +// Gate families live in their own files, composed into the registry below: what a source file +// may contain and how long it may be, the trace chain from brief to commit, whether a stack's +// own gates are wired, and the config's self-gate, which also owns the third-party declaration. import { codeChecks } from './check-code.mjs'; import { checkCommitMessage, traceChecks } from './check-trace.mjs'; import { stackChecks } from './check-stack.mjs'; +import { configChecks, thirdPartyMatcher } from './check-config.mjs'; // The commit-msg hook and the self-test have always imported this from here; it is authored in // check-trace.mjs with the rest of the chain, and stays reachable at its published address. @@ -100,9 +102,13 @@ export function runChecks(root) { // STATE.local.md could fail the pre-commit hook. Drop them from every file-based check. tree.files = tree.files.filter((f) => !basename(f).endsWith('.local.md')); const textFiles = tree.files.filter((f) => TEXT_EXT.has(extname(f)) || f.endsWith('.gitignore')); + // What this project did not write: a declared third-party payload (checks/config.json), read + // by every gate that measures this repo's own writing. House style governs what this repo + // writes; measuring somebody else's payload would force a patch on every upstream release. + const isThirdParty = thirdPartyMatcher(cfg); // What counts as generated or vendored code is one fact, read by the length cap and by the // deferral contract. Written once so the two can never drift apart. - const isVendored = (r) => (cfg.codeFileCapExclude || []).some((x) => r.startsWith(x) || r.endsWith(x)); + const isVendored = (r) => isThirdParty(r) || (cfg.codeFileCapExclude || []).some((x) => r.startsWith(x) || r.endsWith(x)); // Which gate is speaking is the runner's bookkeeping, so a family file reports a failure the // same way a gate written here does: it calls fail(), and the loop below names the caller. @@ -133,6 +139,7 @@ export function runChecks(root) { for (const f of tree.files) { const base = basename(f); if (base !== 'AGENTS.md' && base !== 'CLAUDE.md') continue; + if (isThirdParty(rel(root, f))) continue; const n = lines(f).length; if (n > cap) { fail(`${rel(root, f)} is ${n} lines (hard cap ${cap}): an AGENTS.md/CLAUDE.md past ${cap} lines stops being read in full. Move detail into a skill or docs/.`); @@ -140,63 +147,6 @@ export function runChecks(root) { } }, - 'config-invariants'() { - // checks/config.json is the one file that can weaken every other gate, in the same commit - // as the violation it hides: raise a budget past the rule it encodes, or add an exclusion - // that steers a check away from the paths it exists to police. So the config gates itself. - // Both invariants come from a rule written down elsewhere, never from taste. - const cap = cfg.budgets?.agentFileHardCapLines; - // 200 is not a preference: past it an agent rulebook stops being read in full, so a higher - // cap does not buy a longer file, it buys a file that silently stops governing. The two - // ways to break it read differently, so they are reported differently. - if (cap !== undefined && !(Number.isInteger(cap) && cap > 0)) { - fail(`checks/config.json budgets.agentFileHardCapLines is ${JSON.stringify(cap)}, which is not a positive whole number of lines: agent-file-cap would fall back to 200 and the value would govern nothing.`); - } else if (cap !== undefined && cap > 200) { - fail(`checks/config.json budgets.agentFileHardCapLines is ${cap}: the hard cap is 200 lines and may be lowered, never raised. A rulebook past 200 lines stops being loaded in full.`); - } - // A boolean that retires a whole check is the same weakening vector as an exclusion that - // hides a path, and no reading of the config can tell the legitimate case (a checkout with - // no symlink support) from a gate somebody found inconvenient. So the exemption states its - // case in the same diff that takes it, the trade this repo already made for - // "checks:allow-length: " and "checks:allow-style". skills-symlink still reads the - // key as a plain flag: which value is honest is this gate's question, and one red is enough. - const skip = cfg.skipSymlinkCheck; - if (skip !== undefined && skip !== false && !(typeof skip === 'string' && skip.trim())) { - fail(`checks/config.json skipSymlinkCheck is ${JSON.stringify(skip)}: retiring the skills-symlink check takes a reason in the same file, as a non-empty string (e.g. "Windows without Developer Mode"). Set false to keep the check on.`); - } - // An exclusion that reaches these prefixes disarms the gates rather than tuning them: - // checks/ is where the gates themselves live, docs/standards/ is where a stack's rules do. - // secretScanExclude names checks/ by construction (the detector patterns are in check.mjs - // and would match themselves), so it is the one list allowed to, and only for that prefix. - const protectedPrefixes = ['checks/', 'docs/standards/']; - // Each list is read back by its own matching rule, so the invariant has to test the rule - // that will actually run, or it guarantees less than its message claims. An affix list - // (code-file-cap, secrets) hides a path when either end of it matches; a substring list - // (denylist) hides it when the value appears anywhere inside it. Testing only the prefix - // would let "s/" and "heck" walk past a gate whose whole job is to stop exactly that. - const hides = (mode, v, p) => v === '' || v.startsWith(p) - || (mode === 'affix' ? p.startsWith(v) || p.endsWith(v) : p.includes(v)); - const lists = [ - ['codeFileCapExclude', cfg.codeFileCapExclude || [], protectedPrefixes, 'affix'], - ['secretScanExclude', cfg.secretScanExclude || [], ['docs/standards/'], 'affix'], - ...(cfg.denylist || []).map((e, i) => [`denylist[${i}].exclude`, e.exclude || [], protectedPrefixes, 'substring']), - ]; - for (const [where, values, guarded, mode] of lists) { - for (const v of values) { - if (typeof v !== 'string') { - fail(`checks/config.json ${where} holds ${JSON.stringify(v)}: an exclusion is a path string, and a non-string silently excludes nothing.`); - continue; - } - // Overlap in either direction is a hit: "docs/" swallows docs/standards/ from above, - // "docs/standards/react.md" carves it out from within, "" swallows everything. - const hit = guarded.find((p) => hides(mode, v, p)); - if (hit) { - fail(`checks/config.json ${where} excludes "${v}", which hides ${hit}: that is where the gates (or a stack's standards) live, so excluding it disarms a check instead of tuning it. Narrow the exclusion.`); - } - } - } - }, - 'bridge-claude'() { const body = read(join(root, 'CLAUDE.md')).trim(); if (body !== '@AGENTS.md') { @@ -247,7 +197,7 @@ export function runChecks(root) { for (const f of textFiles) { const r = rel(root, f); if (r.startsWith('checks/') || r.startsWith('docs/specs/archive/') - || r.startsWith('docs/state/log/') || r.startsWith('docs/decisions/')) continue; + || r.startsWith('docs/state/log/') || r.startsWith('docs/decisions/') || isThirdParty(r)) continue; const content = lines(f); for (const e of entries) { if ((e.exclude || []).some((x) => r.includes(x))) continue; @@ -280,7 +230,7 @@ export function runChecks(root) { || r === 'docs/design/VOICE.md'; for (const f of textFiles) { const r = rel(root, f); - if (r.startsWith('checks/')) continue; + if (r.startsWith('checks/') || isThirdParty(r)) continue; const scanPhrases = !phraseSkip(r); lines(f).forEach((line, i) => { if (line.includes('checks:allow-style')) return; @@ -316,6 +266,11 @@ export function runChecks(root) { for (const entry of readdirSync(skillsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; dirs.add(entry.name); + // A declared third-party skill is somebody else's work, installed rather than authored + // here: it carries no row in our routing table and is not held to our frontmatter + // budgets. It stays in `dirs`, so the reverse direction below still catches a table row + // whose directory is gone. + if (isThirdParty(`.agents/skills/${entry.name}`)) continue; const p = join(skillsDir, entry.name, 'SKILL.md'); if (!existsSync(p)) { fail(`skill "${entry.name}" has no SKILL.md`); continue; } const body = read(p); @@ -352,6 +307,7 @@ export function runChecks(root) { ...codeChecks(ctx), ...traceChecks(ctx), ...stackChecks(ctx), + ...configChecks(ctx), 'explainer-stats'() { // The explainer page states counts of what this repo holds. A typed count goes stale the @@ -379,7 +335,11 @@ export function runChecks(root) { // The registry this runner walks, plus those. Reading the object itself is what keeps // the number honest: a gate added or removed moves the count the same day. gates: () => Object.keys(checks).length + hookGates.length, - skills: () => count(join('.agents', 'skills'), (e) => e.isDirectory()), + // Skills this project wrote. A declared third-party payload is installed, not authored, + // and it is gitignored, so counting it would make the page say a different number on a + // machine that has run the install than on a fresh clone. + skills: () => count(join('.agents', 'skills'), + (e) => e.isDirectory() && !isThirdParty(`.agents/skills/${e.name}`)), // Numbered records only: TEMPLATE.md is the form to fill in, not a decision. decisions: () => count(join('docs', 'decisions'), (e) => e.isFile() && /^\d+-.+\.md$/.test(e.name)), }; diff --git a/checks/check.test.mjs b/checks/check.test.mjs index d6e36f7..1b4e204 100644 --- a/checks/check.test.mjs +++ b/checks/check.test.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -// Self-test for checks/check.mjs: the document, rulebook and config gates it still owns, plus +// Self-test for checks/check.mjs: the document and rulebook gates it still owns, plus // the runner's own wiring (hooks, the enforcement self-report, the handoff nudge). Every check // must prove it FAILS on a real violation and stays quiet on a clean repo: an untested gate is -// false confidence (decision 0005). The three gate families that live in their own files are -// proven next door, by check-code.test.mjs, check-trace.test.mjs and check-stack.test.mjs. +// false confidence (decision 0005). The four gate families that live in their own files are +// proven next door, by check-code.test.mjs, check-config.test.mjs, check-trace.test.mjs and +// check-stack.test.mjs. // Run: node checks/check.test.mjs import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, unlinkSync, readFileSync } from 'node:fs'; @@ -109,38 +110,41 @@ expectFail('prose-style', ({ root, put }) => { // config-driven phrase ban put('docs/state/STATE.md', '# STATE\n\n## Handoff\n\n- Now ▶ a seamlessly integrated flow\n'); }); -// config-invariants: the config may be tuned, never disarmed. -expectFail('config-invariants', withConfig({ budgets: { ...BASE_BUDGETS, agentFileHardCapLines: 400 } })); -expectFail('config-invariants', withConfig({ budgets: { ...BASE_BUDGETS, agentFileHardCapLines: '200' } })); -expectFail('config-invariants', withConfig({ codeFileCapExclude: ['checks/'] })); -expectFail('config-invariants', withConfig({ codeFileCapExclude: ['docs/'] })); // swallows docs/standards/ -expectFail('config-invariants', withConfig({ codeFileCapExclude: [''] })); // swallows everything -expectFail('config-invariants', withConfig({ codeFileCapExclude: [123] })); -expectFail('config-invariants', withConfig({ secretScanExclude: ['checks/', 'docs/standards/'] })); -// The evasions a prefix-only invariant would wave through: code-file-cap also matches a -// suffix, and a denylist exclude matches a substring anywhere in the path. -expectFail('config-invariants', withConfig({ codeFileCapExclude: ['s/'] })); -expectFail('config-invariants', withConfig({ - denylist: [{ pattern: 'no-such-text-anywhere', why: 'x', exclude: ['heck'] }], -})); -expectFail('config-invariants', withConfig({ - denylist: [{ pattern: 'no-such-text-anywhere', why: 'x', exclude: ['checks/'] }], -})); -// A boolean that retires the whole skills-symlink check is the same weakening vector as an -// exclusion that hides a path, and nothing in the config tells the legitimate case (no symlink -// support) from the illegitimate one. So the exemption states its case, as allow-length does. -expectFail('config-invariants', withConfig({ skipSymlinkCheck: true })); -expectFail('config-invariants', withConfig({ skipSymlinkCheck: ' ' })); -expectFail('config-invariants', withConfig({ skipSymlinkCheck: 1 })); -// Lowering the cap is allowed; only raising it is a weakening. And the shipped secretScanExclude -// names checks/ by construction, which the clean fixture above already proves stays green. -expectClean('config-invariants-allows-a-lower-cap', withConfig({ - budgets: { ...BASE_BUDGETS, agentFileHardCapLines: 120 }, -})); -expectClean('config-invariants-allows-a-stated-reason', withConfig({ - skipSymlinkCheck: 'Windows without Developer Mode', +// A declared third-party payload (checks/config.json) is somebody else's work, installed rather +// than authored here. The gates that measure this project's own writing skip it; the same text +// at an undeclared path is still this project's, and still fails. Both directions, one fixture +// each, because an exemption that cannot be shown to be bounded is a hole. +const PAYLOAD = { path: 'vendor/upstream/', why: 'installed at its current release, not written here' }; +const declaring = (mutate) => ({ put, root }) => { + put('checks/config.json', JSON.stringify({ + denylist: [{ pattern: 'the retired wording', why: 'it was retired' }], + allowedEmptyDirs: [], secretScanExclude: ['checks/'], budgets: BASE_BUDGETS, thirdParty: [PAYLOAD], + })); + mutate({ put, root }); +}; +const foreignText = 'a line with an em dash — and the retired wording\n'; + +expectClean('third-party-payload-is-not-measured', declaring(({ put }) => { + put('vendor/upstream/README.md', foreignText); + put('vendor/upstream/AGENTS.md', `# their rulebook\n${'filler line\n'.repeat(205)}`); })); +expectFail('prose-style', declaring(({ put }) => put('vendor/ours/README.md', foreignText))); +expectFail('denylist', declaring(({ put }) => put('vendor/ours/README.md', foreignText))); +expectFail('agent-file-cap', declaring(({ put }) => + put('vendor/ours/AGENTS.md', `# rules\n${'filler line\n'.repeat(205)}`))); + +// A declared third-party skill carries no row in the routing table: it was installed, not +// written here. An ordinary skill still must appear, which the "ghost" fixture above proves. +expectClean('third-party-skill-needs-no-routing-row', ({ put }) => { + put('checks/config.json', JSON.stringify({ + denylist: [], allowedEmptyDirs: [], secretScanExclude: ['checks/'], budgets: BASE_BUDGETS, + thirdParty: [{ path: '.agents/skills/installed/', why: 'installed at its current release' }], + })); + put('.agents/skills/installed/SKILL.md', '---\nname: something-else\n---\n'); + put('index.html', '
1
\n'); +}); + expectFail('empty-dirs', ({ root }) => mkdirSync(join(root, 'src', 'hollow'), { recursive: true })); @@ -278,14 +282,21 @@ expectSignal('enforcement-adapter-wired', ({ put }) => put('.claude/settings.json', '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"node x"}]}]}}'), 'adapter hooks', true); -{ // a bare directory still yields all three signals, formatted with a fix line per degraded one +// The design method's payload is gitignored, so a clone starts without it and the report says so +// rather than assuming it. Installed, it reports the version it found. +expectSignal('enforcement-design-method-absent', () => {}, 'design method', false, '--install'); +expectSignal('enforcement-design-method-installed', ({ put }) => + put('.agents/skills/impeccable/SKILL.md', '---\nname: impeccable\nversion: 9.9.9\n---\n'), +'design method', true, '9.9.9'); + +{ // a bare directory still yields all four signals, formatted with a fix line per degraded one const bare = mkdtempSync(join(tmpdir(), 'groundwork-bare-')); try { const report = enforcementReport(bare); - assert.equal(report.length, 3, 'always exactly three signals'); + assert.equal(report.length, 4, 'always exactly four signals'); const lines = formatReport(report); assert.ok(lines[0].startsWith('enforcement: '), 'summary line names the tier'); - assert.equal(lines.length, 4, 'three degraded signals get three fix lines under the summary'); + assert.equal(lines.length, 5, 'four degraded signals get four fix lines under the summary'); tally.passed++; } catch (e) { tally.failed.push(`enforcement-bare-dir: ${e.message}`); } rmSync(bare, { recursive: true, force: true }); diff --git a/checks/cockpit-page.mjs b/checks/cockpit-page.mjs index ee1de47..53c9b55 100644 --- a/checks/cockpit-page.mjs +++ b/checks/cockpit-page.mjs @@ -48,6 +48,7 @@ export const BOARD = { hooks: 'the checks before every commit', CI: 'the check that runs where it cannot be skipped', 'adapter hooks': 'the reminders the agent gets during a session', + 'design method': 'the method the interface is designed with', }, source: 'From', cardFailed: (why) => `This card could not be built: ${why}. The other cards still hold.`, @@ -80,6 +81,7 @@ export const BOARD = { hooks: 'de controles voor elke commit', CI: 'de controle die draait waar niemand hem kan overslaan', 'adapter hooks': 'de herinneringen die de agent tijdens een sessie krijgt', + 'design method': 'de methode waarmee de interface wordt ontworpen', }, source: 'Uit', cardFailed: (why) => `Deze kaart kon niet worden opgebouwd: ${why}. De andere kaarten kloppen nog.`, diff --git a/checks/config.json b/checks/config.json index 4dd2f3a..6fd3ff1 100644 --- a/checks/config.json +++ b/checks/config.json @@ -1,5 +1,5 @@ { - "_doc": "Configuration for checks/check.mjs. denylist: retired facts that may never return in any scanned text file, add the OLD wording whenever a fact changes (see AGENTS.md conflict rule). Each entry: pattern (JS regex source, case-insensitive), why (shown on failure), optional exclude (path substrings to skip). docs/decisions/, archives and checks/ are never scanned by the denylist (decision records may quote retired wording). styleBans: AI-boilerplate phrases banned in all text (prose-style check, decision 0008); same shape as denylist, seeded with tells measured to fire on no legitimate prose, extend per project. The prose-style check also bans AI typography (em dash, en dash, ellipsis and curly quotes) with no config needed; escape a deliberate case with checks:allow-style on the line. commentBans: the deferral-apology set, the negative image of the defer: contract - scanned by defer-markers on comment lines in code files only, because 'for now' is legitimate in a string or a UI label and only a comment can apologise for a simplification. extraTextExtensions/extraCodeExtensions: the stack skill adds this ecosystem's file types here. codeFileCapExclude: path prefixes/suffixes that hold generated or vendored code, skipped by code-file-cap and commentBans, since neither this project's length budget nor its deferral contract governs code it did not write; a comment in the file that opens with checks:allow-length: works too for the length cap, while a mention of that marker in prose or inside a string does not, because the exemption is a declaration a file makes about itself. Exclusion lists are themselves gated by config-invariants: none of them may hide docs/standards/, and only secretScanExclude may name checks/. skipSymlinkCheck: false, or the reason as a string on a checkout without symlink support (Windows without Developer Mode) - config-invariants rejects a bare true, because retiring a whole check states its case like every other exemption here.", + "_doc": "Configuration for checks/check.mjs. denylist: retired facts that may never return in any scanned text file, add the OLD wording whenever a fact changes (see AGENTS.md conflict rule). Each entry: pattern (JS regex source, case-insensitive), why (shown on failure), optional exclude (path substrings to skip). docs/decisions/, archives and checks/ are never scanned by the denylist (decision records may quote retired wording). styleBans: AI-boilerplate phrases banned in all text (prose-style check, decision 0008); same shape as denylist, seeded with tells measured to fire on no legitimate prose, extend per project. The prose-style check also bans AI typography (em dash, en dash, ellipsis and curly quotes) with no config needed; escape a deliberate case with checks:allow-style on the line. commentBans: the deferral-apology set, the negative image of the defer: contract - scanned by defer-markers on comment lines in code files only, because 'for now' is legitimate in a string or a UI label and only a comment can apologise for a simplification. thirdParty: paths this project did not write, each { path, why }: an installed methodology, a vendored SDK. The gates that measure this project's own writing (prose style, denylist, agent-file cap, code-file cap, the deferral contract, zombie code, the skills registry, the document map) skip them; every other gate, secrets included, still applies. Matching is by path prefix, so an upstream rename inside the payload changes nothing here. extraTextExtensions/extraCodeExtensions: the stack skill adds this ecosystem's file types here. codeFileCapExclude: path prefixes/suffixes that hold generated or vendored code, skipped by code-file-cap and commentBans, since neither this project's length budget nor its deferral contract governs code it did not write; a comment in the file that opens with checks:allow-length: works too for the length cap, while a mention of that marker in prose or inside a string does not, because the exemption is a declaration a file makes about itself. Exclusion lists are themselves gated by config-invariants: none of them may hide docs/standards/, and only secretScanExclude may name checks/. skipSymlinkCheck: false, or the reason as a string on a checkout without symlink support (Windows without Developer Mode) - config-invariants rejects a bare true, because retiring a whole check states its case like every other exemption here.", "denylist": [ { "pattern": "0001-0008 are Groundwork's own", "why": "retired manifest wording; the decision-record note no longer carries a number range (it went stale at 0009)" }, { "pattern": "\\b(14 controles|19 skills)\\b", "why": "retired explainer heading counts; headings stay numberless, the hero stat strip owns live counts (drifted once: heading said 14 while check.mjs had 17)" }, @@ -59,6 +59,12 @@ { "pattern": "\\bleft as an exercise\\b|\\bfor demonstration purposes\\b", "why": "tutorial phrasing in shipped code; finish it or mark it with defer:" }, { "pattern": "\\byou would (typically|normally|want to)\\b", "why": "advice to an imaginary reader instead of a decision; state what this code does, or defer: it" } ], + "thirdParty": [ + { + "path": ".agents/skills/impeccable/", + "why": "impeccable, the design methodology this project builds interfaces with (Apache-2.0), installed per project at its current release and gitignored like a dependency. This repo's house style governs what this repo writes; measuring somebody else's payload would force a patch on every upstream release, which is what makes \"always the current version\" impossible (spec 011). Every gate that is not about our own writing still applies to it." + } + ], "extraTextExtensions": [], "extraCodeExtensions": [], "codeFileCapExclude": [], diff --git a/checks/design-method.mjs b/checks/design-method.mjs new file mode 100644 index 0000000..501f003 --- /dev/null +++ b/checks/design-method.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// The design method: impeccable, installed per project at its current release and never vendored +// into this repo (spec 011). The payload is gitignored like a dependency, so this file is both the +// route that puts it there and the reader that says whether it is there. +// Run: node checks/design-method.mjs --install +// +// The install lands in .claude/skills, which is a symlink into .agents/skills here (decision +// 0002), and upstream deliberately drops such a link so each harness gets its own build. So the +// route installs the Claude build, then puts the payload where our skills live and restores the +// link: the same files, reachable under both names, with the symlink gate still green. The +// detector hook is not wired here; `stack` owns that, beside the ecosystem's own gates. + +import { + existsSync, readFileSync, renameSync, rmSync, symlinkSync, lstatSync, readdirSync, rmdirSync, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Where the payload lives once installed. checks/config.json declares this same prefix as +// third-party, and a self-test asserts the two agree, so neither can move without the other. +export const PAYLOAD_PATH = '.agents/skills/impeccable'; +const LANDING_PATH = '.claude/skills/impeccable'; +const SKILLS_LINK = '.claude/skills'; +const PACKAGE = 'impeccable'; + +// The installed version is the skill's own frontmatter, which is where impeccable states it too. +// Reading the payload rather than asking the network keeps this a zero-token, offline reader. +export function designMethod(root) { + const skill = join(root, PAYLOAD_PATH, 'SKILL.md'); + if (!existsSync(skill)) return { installed: false, version: null }; + const m = readFileSync(skill, 'utf8').match(/^version:\s*(.+)$/m); + return { installed: true, version: m ? m[1].trim().replace(/^["']|["']$/g, '') : null }; +} + +// The fourth enforcement signal, same shape as hooks, CI and adapter hooks: a fresh clone has no +// payload (it is gitignored), so the state is reported rather than assumed. +export function designMethodSignal(root) { + const { installed, version } = designMethod(root); + if (installed) { + return { + signal: 'design method', + armed: true, + detail: `impeccable ${version || 'installed'} at ${PAYLOAD_PATH}`, + }; + } + return { + signal: 'design method', + armed: false, + detail: 'design method not installed: run node checks/design-method.mjs --install (a project with no user interface does not need it).', + }; +} + +// The install prints its own progress straight to the terminal, so its stdout is inherited and +// there is nothing to read back; a reader like `npm view` gets a pipe. One helper, both shapes. +const run = (cmd, args, opts = {}) => (execFileSync(cmd, args, { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts, +}) || '').trim(); + +const parts = (v) => String(v).replace(/^\D+/, '').split('.').map(Number); +const below = (have, want) => { + const a = parts(have); const b = parts(want); + for (let i = 0; i < 3; i++) { + if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0); + } + return false; +}; + +// The Node floor comes out of the package that sets it, never out of a number typed here: a +// version read from a note instead of from the source is exactly what `stack` forbids, and it +// would go stale the first time upstream raises it. +function requiredNode() { + const range = run('npm', ['view', `${PACKAGE}@latest`, 'engines.node']); + const min = range.match(/\d+(\.\d+)*/); + return { range, min: min ? min[0] : null }; +} + +// Put the payload where our skills live and restore the symlink. Nothing is deleted that this +// route did not just install: a .claude/skills holding anything else is reported, not cleared. +function adopt(root) { + const link = join(root, SKILLS_LINK); + if (lstatSync(link).isSymbolicLink()) return 'installed through the existing symlink'; + const landed = join(root, LANDING_PATH); + if (!existsSync(landed)) { + throw new Error(`the install left no payload at ${LANDING_PATH}: impeccable changed where it writes, so this route needs updating before it can claim success.`); + } + rmSync(join(root, PAYLOAD_PATH), { recursive: true, force: true }); + renameSync(landed, join(root, PAYLOAD_PATH)); + const left = readdirSync(link); + if (left.length) { + throw new Error(`${SKILLS_LINK}/ still holds ${left.join(', ')}, so the symlink cannot be restored: move those into .agents/skills/ and run ln -sfn ../.agents/skills ${SKILLS_LINK}.`); + } + rmdirSync(link); + symlinkSync('../.agents/skills', link); + return `payload moved to ${PAYLOAD_PATH}, ${SKILLS_LINK} symlink restored`; +} + +// One line per outcome, and a refusal before anything is written: a half-install is worse than +// none, because the checks would then measure a payload nobody can run. +export function install(root) { + let want; + try { + want = requiredNode(); + } catch (e) { + throw new Error(`cannot read what Node version ${PACKAGE} needs (${e.message.split('\n')[0]}): the design method is unavailable until npm is reachable.`); + } + if (want.min && below(process.versions.node, want.min)) { + throw new Error(`Node ${process.versions.node} is below ${PACKAGE}'s requirement (${want.range}): upgrade Node first, nothing was written.`); + } + console.log(`Node ${process.versions.node} meets ${PACKAGE} ${want.range || '(no stated range)'}. Installing...`); + run('npx', ['-y', `${PACKAGE}@latest`, 'install', '--providers=claude', '--scope=project', '--yes', '--no-hooks'], + { cwd: root, stdio: ['ignore', 'inherit', 'inherit'] }); + const what = adopt(root); + const { version } = designMethod(root); + return `impeccable ${version || '(version unstated)'} installed: ${what}.`; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + if (!process.argv.includes('--install')) { + const s = designMethodSignal(root); + console.log(`design method: ${s.armed ? s.detail : `NOT armed. ${s.detail}`}`); + process.exit(0); + } + try { + console.log(install(root)); + } catch (e) { + console.error(`design method NOT installed: ${e.message}`); + process.exit(1); + } +} diff --git a/checks/enforcement.mjs b/checks/enforcement.mjs index 4adb293..629014e 100644 --- a/checks/enforcement.mjs +++ b/checks/enforcement.mjs @@ -1,8 +1,9 @@ // Groundwork enforcement self-report: which enforcement tier does this environment run at? -// A fresh copy silently loses three machine-local layers: git hooks (core.hooksPath is set per -// clone), CI (a workflow only runs when a GitHub remote exists to push to), and the Claude -// adapter's suggest-hooks (.claude/settings.json). Without this report, a hookless clone with -// no remote runs with zero hard gates and no warning (GAP C-2, INTAKE 2026-07-22). +// A fresh copy silently loses four machine-local layers: git hooks (core.hooksPath is set per +// clone), CI (a workflow only runs when a GitHub remote exists to push to), the Claude adapter's +// suggest-hooks (.claude/settings.json), and the design method, whose payload is installed per +// project and gitignored like a dependency. Without this report, a hookless clone with no remote +// runs with zero hard gates and no warning (GAP C-2, INTAKE 2026-07-22). // Report, never block: a weak environment is information, not a violation. The exit code // belongs to the checks alone; checks/check.mjs prints this on every direct run and skips it // under CI, where the runner's own clone (no hooksPath, ephemeral remote) would misread as @@ -11,13 +12,16 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { join } from 'node:path'; +// Whether the design method is installed, and at which version, is read where its install route +// lives, so the report and the installer can never disagree about where the payload sits. +import { designMethodSignal } from './design-method.mjs'; // stderr is swallowed: "not a repo" or "key unset" are expected degraded states, not errors. const git = (root, args) => execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', }).trim(); -// The three signals, each { signal, armed, detail }. Never throws, whatever the directory +// The four signals, each { signal, armed, detail }. Never throws, whatever the directory // looks like: a missing piece is a degraded signal, not a crash. export function enforcementReport(root) { const signals = []; @@ -67,6 +71,10 @@ export function enforcementReport(root) { signals.push({ signal: 'adapter hooks', armed: false, detail: 'Claude adapter hooks not wired (.claude/settings.json): the progress line and handoff nudge never fire.' }); } + // 4. The design method. Its payload is installed per project and gitignored, so a clone starts + // without it and nothing in the repo can tell from the files alone that it should be there. + signals.push(designMethodSignal(root)); + return signals; } diff --git a/checks/links.mjs b/checks/links.mjs index 6f254ee..6f43247 100644 --- a/checks/links.mjs +++ b/checks/links.mjs @@ -23,6 +23,8 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join, relative, resolve, sep, posix, isAbsolute } from 'node:path'; +// Which paths this project did not write is declared in checks/config.json and read there. +import { thirdPartyForRoot } from './check-config.mjs'; // Not part of the project: build output and other people's code. Shared with the gate's own walk // so "which directories are not this project" has one answer. @@ -153,9 +155,12 @@ export function linkGraph(documents, { exists = () => false } = {}) { } // The project's documents, read once for whoever asks: the gate polices exactly the set the -// board draws. +// board draws. A declared third-party payload (checks/config.json) is not this project's +// writing, so it is neither policed nor drawn: an installed methodology ships hundreds of its +// own pages, and counting them would bury the map of what this project actually says. export function readDocuments(root) { const documents = []; + const third = thirdPartyForRoot(root); const walk = (dir) => { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } @@ -164,12 +169,11 @@ export function readDocuments(root) { // project entirely. if (entry.isSymbolicLink()) continue; const full = join(dir, entry.name); + const path = relative(root, full).split('\\').join('/'); + if (third(path)) continue; if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) walk(full); continue; } if (!entry.name.endsWith('.md')) continue; - documents.push({ - path: relative(root, full).split('\\').join('/'), - text: readFileSync(full, 'utf8').replace(/\r\n/g, '\n'), - }); + documents.push({ path, text: readFileSync(full, 'utf8').replace(/\r\n/g, '\n') }); } }; walk(root); diff --git a/docs/specs/011-design-on-impeccable/plan.md b/docs/specs/011-design-on-impeccable/plan.md new file mode 100644 index 0000000..b975b70 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/plan.md @@ -0,0 +1,55 @@ +# 011: plan (tier L) + +## Build order + +Owned by the ticket graph in `tickets/`. Work the frontier, one ticket per fresh session. + +- 01 install route and third-party declaration (no blockers, tracer) +- 02 begin and design route into impeccable (blocked by 01) +- 03 retire taste (blocked by 02) +- 04 design-guard keeps what impeccable does not cover (blocked by 03) +- 05 the detector becomes a real gate (blocked by 01) +- 06 artifact ownership, decision record, doc reconciliation (blocked by 02, 03, 04, 05) + +Ticket 01 is the tracer: it installs the payload into this repo, declares it, keeps every gate +green, and makes the installed state visible. Nothing widens before that narrow path runs for +real, because every later ticket assumes a payload that a green check run tolerates. + +## Seams and interfaces touched + +- **`checks/config.json` plus `check.mjs`'s `isVendored` reader.** The third-party declaration + reuses the existing one-fact-one-place idiom, and extends it from the code gates to the text + gates and the skills registry. Proven by fixtures in the existing runner suite: declared path + with banned typography stays green, undeclared path still fails. +- **`checks/enforcement.mjs`.** Gains one reported state, the design method, beside hooks, CI and + adapter hooks. Proven in both states. +- **`checks/check-stack.mjs`.** The detector counts as wired only when the CI workflow runs it, + the same evidence rule the other stack gates use. +- **The AGENTS.md routing table and the skills gate.** A declared third-party skill directory is + exempt from the registry requirement; an ordinary skill still must appear. +- **The skills themselves (`begin`, `design`, `design-guard`, `verify`, `stack`, `maintain`).** + Their seam is their trigger and their handoff, not their internals. +- **`docs/design/DESIGN.md`, `docs/product/BRIEF.md` and PRODUCT.md.** The seam is which file owns + which fact; the links gate and the docs manifest prove the pointers. + +## Migration / rollback + +Existing copies of Groundwork keep working: nothing here is required for a project that has no +interface, and a project already mid-build keeps its current DESIGN.md until it chooses to run the +new route. The one destructive step is ticket 03, the removal of `taste`; its content is covered by +impeccable's craft floor, and the decision record states what moved where, so a revert is a plain +`git revert` of that ticket's commit plus restoring the AGENTS.md row. + +Rollback of the whole spec is a git revert plus `rm -rf .agents/skills/impeccable`, since the +payload is gitignored and nothing in the repo depends on its internals. + +## Verification approach + +Per ticket: the gates every commit already runs (`node checks/check.mjs`, the four self-test +suites, progress, links, cockpit, drill), plus that ticket's own acceptance list. + +For the spec as a whole, `verify` runs the twelve acceptance criteria end to end, and criteria 1, +2 and 4 are exercised for real rather than reasoned about: a scratch project gets set up through +`begin`, the install is run and its failure path is forced once by cutting the network, and one +small surface is taken through the three approval points to confirm the owner is actually asked +before code exists. What cannot be exercised in a scratch run is stated as such. diff --git a/docs/specs/011-design-on-impeccable/spec.md b/docs/specs/011-design-on-impeccable/spec.md new file mode 100644 index 0000000..defe9d5 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/spec.md @@ -0,0 +1,150 @@ +# 011: design runs on impeccable, and the owner decides at three points + +- **Status:** building +- **Traces to:** BRIEF SC-8 ("What ships does not read or look machine-made: the design and the + words follow a system the owner chose") plus the owner's explicit request of 2026-08-05: + "Ik wil https://github.com/pbakaus/impeccable vooral als de methodiek om het design te maken. + Laat het werken met wat we al hebben staan." +- **Owner sign-off:** Getekend 2026-08-05 door de eigenaar: impeccable wordt de methodiek om + design te maken, de zes forks staan zoals ze beantwoord zijn, en de bouw begint bij ticket 01. +- **Ownership of SC-8:** the archived baseline spec 000 shipped SC-8's first version and stays the + record of it. From this spec on, the design half of SC-8 (how an interface is made and judged) is + owned here; the words half stays with VOICE.md and the prose gate. + +## Why + +Groundwork's design layer is three skills and two documents, and it has no mechanical floor at +all: of the 22 gates, not one looks at a rendered interface. Beyond that, the owner sees the work +exactly once, at `design` step 5, after the visual direction has already been chosen by the model +and built. Everything before that moment is model taste, and the failure mode this repo cares +about most, output that reads as machine-made, is decided in precisely that unwatched stretch. + +Impeccable (Apache-2.0, v3.5.0 at the time of writing) closes both halves. Its method picks a +visual world out of seven candidates drawn from the audience's own culture, hands the choice to +the owner on a browser decision page with palettes, first viewports and honest risks, renders +compositions for approval before code exists, builds against the approved composition, and closes +with a review by an agent that never saw the build thread. Underneath that sits a deterministic +detector: 59 rules, no model, no API key, the shape of a gate this repo already knows how to run. + +The owner's requirement is control that arrives in time to change the outcome, on any product +type, at a level that reads as the work of a senior studio. + +## What: acceptance criteria + +1. WHEN `begin` sets up a project whose product has a user interface, THE SYSTEM SHALL install + impeccable at its current release into that project and report the installed version to the + owner. A project with no interface is not made to carry it. +2. WHEN the installation cannot complete (no network, no npm, a refused download), THE SYSTEM + SHALL say so in one line, record the gap in STATE.md, and continue setup rather than failing + the project. +3. WHEN impeccable is installed, `node checks/check.mjs` SHALL pass unchanged. Its payload is + declared third-party in `checks/config.json`, which exempts it from the gates that measure this + project's own writing (prose style, denylist, agent-file cap, code-file cap, the deferral + contract, zombie code), from the skills registry and its count, and from the document map that + the links gate and the board draw. Every other gate, the secret scan included, still applies to + it, and `config-invariants` still bounds the list: a declaration states its reason and may not + reach `checks/` or `docs/standards/`. +4. WHEN a new surface or a replacement visual world is built, the owner SHALL be asked at three + points, in this order: the visual direction, the rendered compositions, and the finish verdict. + No production code for a new visual world is written before the direction is chosen, and the + finish verdict is reported with its open items intact, never summarized into a pass. +5. `docs/design/DESIGN.md` sections 1 and 2 (the ten principles and the owner's standing taste) + SHALL remain binding input that impeccable reads before choosing a direction, and section 3 + SHALL be written from the built world after the finish review, not before the build. +6. PRODUCT.md SHALL hold only what `docs/product/BRIEF.md` does not already own (platform, stack, + brand commitments, evidence on hand, accessibility needs) and SHALL point at BRIEF.md for + scope, users and purpose. No fact is stated in both files. +7. The skill `taste` SHALL be gone: removed from `.agents/skills/`, from the AGENTS.md table, and + from every pointer that names it, with decision 0012 marked superseded and its retired wording + added to the denylist. +8. `design-guard` SHALL no longer restate the frontend build rules. It judges user-facing output + that impeccable does not cover (documents, e-mails, error messages, generated files) and + re-checks a rendered interface against its direction contract. +9. WHEN the project has a frontend, `stack` SHALL wire the impeccable detector into CI beside the + ecosystem's own typecheck, lint and tests, and the edit hook SHALL be installed so tells surface + while the code is being written. `stack-gates` SHALL count the detector as wired only when the + CI job actually runs it. +10. `maintain`'s dependency round SHALL refresh impeccable, so "the current release" stays true + after the first day. +11. A decision record SHALL carry the whole choice: why an external methodology beats another + in-house skill, what it supersedes, and what it costs. +12. `node checks/check.mjs`, `node checks/progress.mjs`, the links gate and the self-test suites + SHALL be green at the end, and no document SHALL still describe the retired arrangement. + +## Failure modes + +- **Install fails (offline, proxy, npm refusal).** The owner sees one line naming what failed and + what design work is unavailable until it is installed; setup continues; the gap lands in STATE.md + as a named blocker. Nothing silently falls back to model defaults without saying so. +- **Upstream renames or restructures the payload.** Our pointers name the skill and its commands, + never internal file paths, so a rename degrades to "the command was not found" instead of a + broken repo. The third-party declaration is a path prefix, which survives internal moves. +- **A project sits below the Node version impeccable requires.** The install step reads that + requirement from the package that states it (`engines.node`, 22.12 or newer at v3.5.0) and + refuses before writing anything, rather than half-installing. The floor is never typed into our + own text, so an upstream bump does not leave a stale number behind. +- **The detector's dependencies fail to resolve in CI.** The detector job fails loudly like any + other gate. It never gets skipped to make a build green. +- **Payload absent on a fresh clone.** The payload is gitignored, so a clone starts without it. + `enforcement.mjs` reports the design method as not installed, in the same line where it already + reports hooks and CI, so the state is visible rather than assumed. + +## Settled decisions + +- **Installed per project at its current release, never vendored into Groundwork.** The owner asked + for the latest version, always. A vendored copy pins a version and turns every upstream release + into hand work, which decision 0017 accepts for our own text but would be a poor trade for 2.3 MB + of somebody else's. +- **The payload is gitignored, like a dependency.** It is reinstalled and refreshed, not committed. + The shared artifacts impeccable produces (`.impeccable/config.json`, `design.json`, the critique + reports) stay tracked, per its own documented split. +- **The gates declare it third-party rather than measure it.** Chosen by the owner on 2026-08-05. + This repo's house style governs what this repo writes; measuring an external skill's prose would + force a patch on every update, which is the drift that makes "always current" impossible. The + declaration is one list in `checks/config.json`, in the open, bounded by `config-invariants`. +- **`taste` is retired rather than kept beside it.** Chosen by the owner on 2026-08-05. Its rules + are covered by impeccable's craft floor and Persuade mode; two anti-slop rulebooks side by side + is the situation AGENTS.md exists to prevent. +- **DESIGN.md's world is written after the build.** Chosen by the owner on 2026-08-05, following + impeccable's own reasoning: a rulebook written before the build gets defended against reality. + The owner's principles and standing taste keep their place as input that binds the choice. +- **Three approval points, not two and not four.** Chosen by the owner on 2026-08-05. Live browser + iteration stays available on request rather than becoming a per-screen obligation. +- **The detector runs in CI and as an edit hook.** Chosen by the owner on 2026-08-05. It is the + first mechanical check on rendered quality this framework has ever had. + +## Testing seams + +- `checks/check.mjs` plus its self-test suite: the third-party declaration is proven by a fixture + that puts a file with banned typography inside a declared payload path and asserts the run stays + green, and a second fixture that asserts an undeclared path still fails. +- `checks/enforcement.mjs`: the design-method line is asserted in both states, installed and not. +- `checks/check-stack.mjs`: the detector counts as wired only when the CI workflow runs it, proven + the way the existing stack gates are proven. +- The skills registry gate: a fixture asserts a declared third-party skill directory does not have + to appear in the AGENTS.md table, while an ordinary skill still must. +- The links gate and `docs/README.md` manifest: every pointer this change moves is proven by the + gates that already own it. + +## Not in this change + +- No product is built to demonstrate the methodology. The owner's standing call on proof holds. +- No change to VOICE.md or the prose gate: language rules are not what this change touches. +- No native or game-engine design guidance beyond what impeccable ships (web, iOS, Android, + adaptive). Unity, Godot and console interfaces stay with `design-guard` and are named as a limit, + not silently implied to be covered. +- No replacement of `docs/design/reference/ui-library-showcase.html` or the UI-foundation decision + (0009). The foundation choice stays a Groundwork decision that impeccable builds within. +- No change to the cockpit, the explainer or the gate count they state. + +## Risks and open questions + +- **Node floor.** Impeccable requires Node 22.12 or newer (its own `engines.node` at v3.5.0), + while which Node this repo's gates target is still open (intake row 50). This change states the + requirement for the design method only, and does not settle the gates' own floor. +- **Two interviews at project start.** `begin` already interviews the owner, and impeccable's init + interviews again for product truth. Ticket 02 must feed what `begin` already knows into init so + the owner is not asked the same thing twice. If that cannot be made clean, the honest fallback is + to let init ask only its platform and stack questions. +- **A third-party methodology can move under us.** The mitigation is that our pointers name + commands, not internals, and that `maintain` refreshes deliberately rather than automatically. diff --git a/docs/specs/011-design-on-impeccable/tickets/01-install-route-and-declaration.md b/docs/specs/011-design-on-impeccable/tickets/01-install-route-and-declaration.md new file mode 100644 index 0000000..35b0ad3 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/tickets/01-install-route-and-declaration.md @@ -0,0 +1,64 @@ +# 01: the payload installs, and every gate stays green + +- **Blocked by:** none +- **Status:** done +- **Traces to:** BRIEF SC-8 + +**What to build:** A Groundwork project can install impeccable at its current release, and the +repo stays honest about it: the checks pass with the payload present, the payload is not committed, +and anyone can see from one command whether the design method is installed here. + +This is the tracer. It runs on this repo, which has its own frontend (the explainer page and the +cockpit), so the path is proven on real files rather than on a fixture alone. + +**Acceptance:** + +- [x] `node checks/design-method.mjs --install` completes in this repo and leaves a usable skill + behind, reachable through the `.claude/skills` symlink. It runs + `npx impeccable@latest install --providers=claude --scope=project`, then moves the payload + to `.agents/skills/impeccable` and restores the symlink, because upstream deliberately + drops a `.claude/skills` link that points at another provider's skills dir. Rerunning it is + a no-op: the second run refreshes through the symlink and leaves it standing. +- [x] The install refuses early and says why when Node is below what impeccable requires, instead + of half-writing. The requirement is read from the package's own `engines.node` (22.12 or + newer at v3.5.0), never typed into our text where an upstream bump would leave it stale. +- [x] `checks/config.json` declares the payload path as third-party, with the reason written in the + entry, and `config-invariants` accepts it while still rejecting an entry that would hide + `docs/standards/`, one that would hide `checks/`, and one that gives no reason. +- [x] `node checks/check.mjs` passes with the payload installed. Specifically: prose style, the + denylist, the agent-file cap, the code-file cap, the deferral contract, zombie code, the + skills registry and the document map do not measure it. The secrets gate still reads it. +- [x] An undeclared directory holding the same banned typography still fails prose style, proven by + a fixture in the existing runner suite. +- [x] A declared third-party skill directory does not have to appear in the AGENTS.md skills table, + while an ordinary skill still fails when it is missing from the table. Both proven by + fixtures, and the explainer's skills count leaves the installed payload out, so the page + states the same number on a fresh clone as on a machine that has run the install. +- [x] `.gitignore` ignores the payload and keeps impeccable's shared artifacts tracked + (`.impeccable/config.json`, `.impeccable/design.json`, `.impeccable/critique/`), following the + block impeccable documents, with our own marker comment saying where it came from. +- [x] `node checks/check.mjs` reports the design method beside hooks, CI and adapter hooks, in both + states: installed with its version, or not installed. +- [x] The self-test suites and the drill stay green. + +**What the tracer found, and what it cost:** + +- **Upstream removes our symlink on purpose.** `.claude/skills -> ../.agents/skills` is what + impeccable calls a legacy in-project provider link, and its installer drops it so each harness + gets its own compiled build. The route installs the Claude build and then restores decision + 0002's arrangement, which also keeps the Claude-only frontmatter (`user-invocable`, + `allowed-tools`) that the Codex build does not carry. The `skills-symlink` gate is the net if a + future release changes this again. +- **The declaration needed a home, and check.mjs was at its cap.** `config-invariants` moved to + `checks/check-config.mjs` with its own suite, the same split `code-file-cap` forced on PR #69. + The gate registry is unchanged: same names, same count. +- **`code-file-cap` was reading the exclusion list itself** instead of the runner's one reader, + so the two could drift. It now uses `isVendored`, which is what that reader exists for. +- **The document map leaves the payload out, and that one is precaution rather than repair.** + Measured: the 40 markdown files impeccable ships hold zero broken asserted links today, so the + links gate would have stayed green. What it would not have stayed is readable: the map went from + 97 documents to 137, burying what this project actually says under somebody else's pages, and an + upstream typo in a release we do not control would turn a gate red on a file we cannot fix. +- **Not done here:** the install runs with `--no-hooks`, so nothing is wired into + `.claude/settings.local.json` yet. The detector becomes a CI gate and an edit hook in ticket 05, + which is where that belongs. diff --git a/docs/specs/011-design-on-impeccable/tickets/02-begin-and-design-route-into-impeccable.md b/docs/specs/011-design-on-impeccable/tickets/02-begin-and-design-route-into-impeccable.md new file mode 100644 index 0000000..e5d687b --- /dev/null +++ b/docs/specs/011-design-on-impeccable/tickets/02-begin-and-design-route-into-impeccable.md @@ -0,0 +1,34 @@ +# 02: setup installs it, and design work runs through it + +- **Blocked by:** 01-install-route-and-declaration.md +- **Status:** ready +- **Traces to:** BRIEF SC-8 + +**What to build:** Someone starting a project that has an interface gets the design method +installed during setup, without answering the same interview twice, and the first real screen is +built through impeccable with the owner deciding at three points: the visual direction, the +rendered compositions, and the finish verdict. + +`design` stops being a method of its own and becomes the Groundwork side of the seam: it carries +what is genuinely ours (the owner's principles and standing taste as binding input, the UI +foundation decision, the language and accessibility floor) and hands the making of the design to +impeccable. + +**Acceptance:** + +- [ ] `begin` asks once whether the product has a user interface, and installs impeccable only then, + reporting the installed version. +- [ ] A failed install is reported in one line naming what is unavailable, lands in STATE.md as a + named blocker, and does not stop setup. +- [ ] What `begin` already captured (users, purpose, positioning, constraints) reaches impeccable's + init instead of being asked again; the owner is asked only what init genuinely adds. +- [ ] `design` names impeccable as the method for making an interface, and states the three + approval points as the order of work. +- [ ] DESIGN.md sections 1 and 2 are handed over as binding input before a direction is chosen, and + a direction that ignores them is sent back rather than accepted. +- [ ] The UI-foundation choice (decision 0009) still happens on the Groundwork side and still lands + as a decision record. +- [ ] Exercised for real: one small surface is taken from nothing to a built screen, and the owner + is asked at all three points, with no production code for the visual world written before the + direction is chosen. +- [ ] `node checks/check.mjs` and the self-test suites stay green. diff --git a/docs/specs/011-design-on-impeccable/tickets/03-retire-taste.md b/docs/specs/011-design-on-impeccable/tickets/03-retire-taste.md new file mode 100644 index 0000000..a892a08 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/tickets/03-retire-taste.md @@ -0,0 +1,27 @@ +# 03: taste is retired, and nothing points at a skill that is gone + +- **Blocked by:** 02-begin-and-design-route-into-impeccable.md +- **Status:** ready +- **Traces to:** BRIEF SC-8 + +**What to build:** The framework carries one rulebook for building an interface. `taste` is +removed, everything that named it points at the method that replaced it, and the retired wording +can never quietly return. + +Before deleting, each of taste's rules is checked against impeccable's craft floor and Persuade +mode. Anything genuinely not covered (the three dials, the redesign protocol's preserve-or-overhaul +read, the SEO migration risk) moves to the file that owns it rather than disappearing, and the +decision record states where each went. + +**Acceptance:** + +- [ ] Every rule in `taste` is accounted for: covered by impeccable, moved to a named file, or + dropped with a reason. The mapping is written in the decision record, not in a commit message. +- [ ] `.agents/skills/taste/` is gone and its row is out of the AGENTS.md skills table. +- [ ] Every pointer that named it is repointed: DESIGN.md principle 10, `design`, `design-guard`, + and any doc the links gate finds. +- [ ] Decision 0012 is marked superseded, naming the decision that replaced it, and stays readable + as the record of why the earlier choice was right at the time. +- [ ] The retired wording is in the denylist in `checks/config.json`, so a later session cannot + reintroduce a rulebook that no longer exists. +- [ ] `node checks/check.mjs`, the links gate and the self-test suites stay green. diff --git a/docs/specs/011-design-on-impeccable/tickets/04-design-guard-keeps-what-is-not-covered.md b/docs/specs/011-design-on-impeccable/tickets/04-design-guard-keeps-what-is-not-covered.md new file mode 100644 index 0000000..5937906 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/tickets/04-design-guard-keeps-what-is-not-covered.md @@ -0,0 +1,26 @@ +# 04: design-guard judges what impeccable does not + +- **Blocked by:** 03-retire-taste.md +- **Status:** ready +- **Traces to:** BRIEF SC-8 + +**What to build:** One judgment check before delivering user-facing output, with no duplicated +lists. Impeccable owns the frontend, so `design-guard` stops restating its rules and keeps the +ground it actually covers: generated documents, e-mails, error messages, exports, and anything +rendered on a platform impeccable does not carry. + +For a frontend, `design-guard` re-checks the rendered result against its own direction contract and +the finish verdict, and reports what is still open. It never re-opens a hunt the finish reviewer +already closed. + +**Acceptance:** + +- [ ] `design-guard` no longer restates rules that impeccable's craft floor owns; what remains is + what it alone covers, plus the render check against the direction contract. +- [ ] Output that is not an interface (a generated document, an e-mail, an error message) is still + fully covered, proven on a real example. +- [ ] Platforms impeccable does not carry (game engines, console and embedded interfaces) are named + as this skill's ground, so nobody assumes coverage that does not exist. +- [ ] `verify` still routes a UI change to the right check, and its pointer is correct. +- [ ] The accessibility floor and its link to COMPLIANCE.md survive the edit unchanged. +- [ ] `node checks/check.mjs` and the self-test suites stay green. diff --git a/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md b/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md new file mode 100644 index 0000000..adf7b56 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md @@ -0,0 +1,23 @@ +# 05: the first mechanical check on rendered quality + +- **Blocked by:** 01-install-route-and-declaration.md +- **Status:** ready +- **Traces to:** BRIEF SC-8 + +**What to build:** A project with a frontend cannot ship a page full of the tells this framework +says it refuses. The deterministic detector runs in CI beside the ecosystem's own typecheck, lint +and tests, and it runs on the developer's edits while the code is being written. + +**Acceptance:** + +- [ ] `stack` wires the detector into the CI workflow it generates, for projects with a frontend, + and says in `docs/standards/.md` what it checks and how a false positive is waived. +- [ ] The edit hook is installed with the payload and reports findings while building. +- [ ] `stack-gates` counts the detector as wired only when the CI workflow actually runs it, the + same evidence rule the existing stack gates use, proven by a fixture in both directions. +- [ ] A page carrying a known tell fails the CI job; the same page with the tell removed passes. + Exercised for real on this repo's own explainer page in a scratch branch, not reasoned about. +- [ ] Waivers are visible: a rule ignored for a file states its reason in the config, which is what + impeccable's own ignore mechanism records. +- [ ] The detector's own dependency failures fail the job loudly; nothing skips to green. +- [ ] `node checks/check.mjs` and the self-test suites stay green. diff --git a/docs/specs/011-design-on-impeccable/tickets/06-artifact-ownership-and-record.md b/docs/specs/011-design-on-impeccable/tickets/06-artifact-ownership-and-record.md new file mode 100644 index 0000000..052c5b5 --- /dev/null +++ b/docs/specs/011-design-on-impeccable/tickets/06-artifact-ownership-and-record.md @@ -0,0 +1,30 @@ +# 06: one fact, one file, and the choice on the record + +- **Blocked by:** 02-begin-and-design-route-into-impeccable.md, 03-retire-taste.md, + 04-design-guard-keeps-what-is-not-covered.md, 05-detector-becomes-a-gate.md + +- **Status:** ready +- **Traces to:** BRIEF SC-8 + +**What to build:** The two documents impeccable expects fit into Groundwork's own map without +saying anything twice, the design method stays current after the first day, and the whole choice is +readable a year from now by someone who was not here. + +**Acceptance:** + +- [ ] PRODUCT.md holds only what BRIEF.md does not own (platform, stack, brand commitments, + evidence on hand, accessibility needs) and points at BRIEF.md for scope, users and purpose. + No fact appears in both, proven by reading the two files against each other. +- [ ] The design context lives where impeccable finds it without configuration, and the location is + named in the AGENTS.md map so nobody has to search for it. +- [ ] DESIGN.md section 3 is written from the built world after the finish review, and sections 1 + and 2 keep their role as input. The template says so in the file itself. +- [ ] `maintain`'s dependency round refreshes impeccable, so the current release stays current. +- [ ] A decision record carries the choice: why an external methodology beat another in-house + skill, what it supersedes, where each retired rule went, what it costs, and where that cost + is paid on the tier ladder of decision 0015. +- [ ] `docs/README.md`, the AGENTS.md map and any doc the change made stale are reconciled, and + retired wording is in the denylist. +- [ ] `node checks/check.mjs`, `node checks/progress.mjs`, the links gate, the cockpit and the four + self-test suites are green, and `node checks/progress.mjs --links` reports no path pointing at + nothing.