From 8bbfd68687d111d4549b647770b44f62e87e34b4 Mon Sep 17 00:00:00 2001 From: Remon Panman <228601219+Tradebaas@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:30:12 +0200 Subject: [PATCH] feat(checks): the design method installs, and the gates say whether it is here Spec 011 is signed, and this is its tracer: the narrow path from "impeccable is the methodology now" to "every gate still holds", run on this repo rather than on a fixture, because this repo has a frontend of its own. The route is one command, checks/design-method.mjs --install. It reads the Node floor out of impeccable's own engines.node instead of a number typed here, so an upstream bump cannot leave a stale requirement behind, and it refuses before writing anything when the floor is not met. Then it installs the Claude build and repairs decision 0002: upstream deliberately deletes a .claude/skills symlink that points at another provider's skills directory, calling it a legacy install artifact, so the route moves the payload to .agents/skills/impeccable and relinks. A second run is a no-op, and skills-symlink is the net if a future release changes this again. The payload is gitignored like the dependency it is, which means a clone starts without it and nothing in the files can tell that it should be there. So enforcement.mjs gains a fourth signal beside hooks, CI and adapter hooks: installed with its version, or not installed with the command that fixes it. The board says the same thing in both languages. What the gates measure is now a declaration rather than an accident. checks/config.json holds one thirdParty list, each entry a path and its reason, and the gates that judge this project's own writing skip those paths: prose style, the denylist, the agent-file cap, the code-file cap, the deferral contract, zombie code, the skills registry and its count, and the document map. Every other gate still reads every line, the secret scan included. The declaration is bounded by config-invariants exactly like the exclusion lists beside it: it states its reason, it may not reach checks/ or docs/standards/, and fixtures prove both directions, because an exemption nobody can show to be bounded is a hole. Two of this repo's own gates shaped the change. code-file-cap refused to let check.mjs grow past its 500-line cap, so config-invariants moved into checks/check-config.mjs with its own suite, the same split PR #69 made for the same reason; the registry still holds 22 gates, since a gate moved rather than multiplied. And the move exposed that code-file-cap read the exclusion list itself instead of the runner's one reader, so the two could have drifted apart without anyone noticing. The spec's Node floor said 22.18, read from a summary rather than from the source; the package says 22.12. Criterion 3 named a narrower set of exempt gates than the code needs. Both are corrected here, because the code is the fact. Traces-to: SC-8 --- .github/workflows/ci.yml | 1 + .gitignore | 30 ++++ checks/check-code.mjs | 5 +- checks/check-code.test.mjs | 15 ++ checks/check-config.mjs | 93 +++++++++++ checks/check-config.test.mjs | 96 +++++++++++ checks/check.mjs | 86 +++------- checks/check.test.mjs | 83 +++++----- checks/cockpit-page.mjs | 2 + checks/config.json | 8 +- checks/design-method.mjs | 131 +++++++++++++++ checks/enforcement.mjs | 18 ++- checks/links.mjs | 14 +- docs/specs/011-design-on-impeccable/plan.md | 55 +++++++ docs/specs/011-design-on-impeccable/spec.md | 150 ++++++++++++++++++ .../01-install-route-and-declaration.md | 64 ++++++++ ...-begin-and-design-route-into-impeccable.md | 34 ++++ .../tickets/03-retire-taste.md | 27 ++++ ...-design-guard-keeps-what-is-not-covered.md | 26 +++ .../tickets/05-detector-becomes-a-gate.md | 23 +++ .../06-artifact-ownership-and-record.md | 30 ++++ 21 files changed, 879 insertions(+), 112 deletions(-) create mode 100644 checks/check-config.mjs create mode 100644 checks/check-config.test.mjs create mode 100644 checks/design-method.mjs create mode 100644 docs/specs/011-design-on-impeccable/plan.md create mode 100644 docs/specs/011-design-on-impeccable/spec.md create mode 100644 docs/specs/011-design-on-impeccable/tickets/01-install-route-and-declaration.md create mode 100644 docs/specs/011-design-on-impeccable/tickets/02-begin-and-design-route-into-impeccable.md create mode 100644 docs/specs/011-design-on-impeccable/tickets/03-retire-taste.md create mode 100644 docs/specs/011-design-on-impeccable/tickets/04-design-guard-keeps-what-is-not-covered.md create mode 100644 docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md create mode 100644 docs/specs/011-design-on-impeccable/tickets/06-artifact-ownership-and-record.md 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.