diff --git a/.agents/skills/begin/SKILL.md b/.agents/skills/begin/SKILL.md index 54b3d9c..68ba074 100644 --- a/.agents/skills/begin/SKILL.md +++ b/.agents/skills/begin/SKILL.md @@ -39,6 +39,10 @@ went wrong or needs the owner's action; a list of deleted template files is nois design method would read as this project's product truth. Delete it: the method's own init writes this project's, in the same place, from what the interview below already captured. `docs/DESIGN.md` stays: sections 1 and 2 ship with Groundwork and are input to that method. +- `docs/standards/node.md` is Groundwork's own stack and its own floor table, which `stack-gates` + would hold this project to. Delete it: a copy has not chosen a stack yet, and the `stack` skill + writes this project's own from `docs/standards/TEMPLATE-STACK.md`. `GLOBAL.md` and the template + stay - they are the cross-stack floor and the shape, not Groundwork's answers. - `docs/operations/deploy.md` holds Groundwork's own release route, which would read as this project's if it stayed. Put the blank runbook back (`cp docs/operations/TEMPLATE-DEPLOY.md docs/operations/deploy.md`). `deliver` fills it at first diff --git a/checks/check-stack.mjs b/checks/check-stack.mjs index cf14052..1a44e1b 100644 --- a/checks/check-stack.mjs +++ b/checks/check-stack.mjs @@ -9,6 +9,20 @@ // moment any workflow file exists. So between choosing a stack and wiring its gates there is a // window where every signal reads green and not one line of the project's code is checked. // This gate closes that window. +// +// It used to close it by looking for the commented-out placeholder stages and failing while any +// remained. That was satisfiable by deleting them, which is one of the two fixes its own message +// proposed, so a project could reach green with a stack declared and nothing wired at all - +// measured on a fresh copy, 2026-08-25. Since E-02/F-01/S-02 it reads the floor table in the +// stack file instead: six classes of risk, each answered with a command, a reasoned +// `not applicable`, or a named `manual` check with a defer: marker. Absence of a comment proved +// nothing; presence of a running stage proves something. docs/standards/TEMPLATE-STACK.md owns +// what the six classes are and what each one covers. +// +// What it still refuses to do is judge the answer. It never looks at which tool a command runs, +// what that tool asserts, or whether a threshold is sane. A gate that pretended to would be the +// false confidence this whole epic exists to remove, and the honest limit is written into the +// epic rather than discovered later. import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -25,6 +39,44 @@ const stackFiles = (standards) => readdirSync(standards, { withFileTypes: true } // how a workflow claims a check it never performs. const runsDetector = (line) => !/^\s*#/.test(line) && /impeccable/i.test(line) && /\bdetect\b/.test(line); +// The six classes of risk every product carries, in the order the floor table lists them. +// docs/standards/TEMPLATE-STACK.md owns what each one covers; this file only checks it is +// answered. Adding a class here without adding it there would fail every project at once. +const CLASSES = ['builds', 'behaves', 'analyzed', 'dependencies', 'secrets', 'renders']; +const FORMS = ['command', 'not applicable', 'manual']; +const FORMS_SAID = 'a command, `not applicable` with a reason, or `manual` with a named check and a defer: marker'; + +// The floor table, and only that table. A stack file may carry a second one (the worked answers +// the template ships with, or the project's own), so the section heading is the anchor: what is +// read is what stands under "## The floor" up to the next heading of that level. +function floorRows(text) { + const lines = text.split('\n'); + const start = lines.findIndex((l) => /^##\s+the floor\s*$/i.test(l)); + if (start < 0) return null; + const rest = lines.slice(start + 1); + const end = rest.findIndex((l) => /^##\s/.test(l)); + const body = (end < 0 ? rest : rest.slice(0, end)); + const rows = new Map(); + for (const line of body) { + const cells = line.split('|').map((c) => c.trim()); + if (cells.length < 5) continue; + const key = (cells[1] || '').replace(/`/g, '').toLowerCase(); + if (!CLASSES.includes(key)) continue; + rows.set(key, { form: (cells[3] || '').replace(/[*`]/g, '').trim().toLowerCase(), answer: cells[4] || '' }); + } + return rows; +} + +// Every backticked span in an answer is a thing that has to run. One cell may hold more than one: +// an audit and an SBOM are two commands answering one class, and both have to be live or the +// class is half answered. +const commandsIn = (answer) => [...answer.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim()).filter(Boolean); + +// A workflow line that runs something, as opposed to one that talks about running it. The same +// rule the design half has always held: a commented stage is how a workflow claims a check it +// never performs. +const liveLines = (lines) => lines.filter((l) => !/^\s*#/.test(l)); + export const stackChecks = ({ root, fail, lines }) => ({ 'stack-gates'() { // Another CI host is explicitly allowed (`stack` section 3: "or this host's equivalent"), @@ -32,26 +84,57 @@ export const stackChecks = ({ root, fail, lines }) => ({ const wfDir = join(root, '.github', 'workflows'); if (!existsSync(wfDir)) return; const workflows = readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n)); + const live = workflows.flatMap((name) => liveLines(lines(join(wfDir, name)))); const standards = join(root, 'docs', 'standards'); const stacks = existsSync(standards) ? stackFiles(standards) : []; - if (stacks.length) { - for (const name of workflows) { - lines(join(wfDir, name)).forEach((line, i) => { - if (!/^\s*#\s*(-\s*name:|---\s*Stack gates)/.test(line)) return; - fail(`.github/workflows/${name}:${i + 1} still carries a commented-out stack gate while docs/standards/ names a stack (${stacks.join(', ')}). Until that stage is filled in, CI proves Groundwork's own rules and nothing about this project's code. Replace the placeholders with this stack's real gates per the skill \`stack\` section 3, and delete the ones this stack has no equivalent for instead of leaving them commented.`); - }); + for (const name of stacks) { + const rel = `docs/standards/${name}`; + const text = lines(join(standards, name)).join('\n'); + const rows = floorRows(text); + if (!rows) { + fail(`${rel} declares a stack and carries no floor table, so nothing says how this project's own code is checked. Copy the table from docs/standards/TEMPLATE-STACK.md and answer all six classes: ${CLASSES.join(', ')}.`); + continue; + } + for (const cls of CLASSES) { + const row = rows.get(cls); + if (!row || !row.form || !row.answer) { + fail(`${rel} leaves the \`${cls}\` class unanswered. Every class is answered one of three ways: ${FORMS_SAID}. An unanswered class is not a floor with a hole in it, it is a hole nobody decided about.`); + continue; + } + if (!FORMS.includes(row.form)) { + fail(`${rel} answers \`${cls}\` with "${row.form}", which is not one of the three forms: ${FORMS_SAID}.`); + continue; + } + if (row.form === 'command') { + const wanted = commandsIn(row.answer); + if (!wanted.length) { + fail(`${rel} answers \`${cls}\` with a command and names none. Put the command in backticks, exactly as a workflow runs it.`); + continue; + } + for (const cmd of wanted) { + if (!live.some((l) => l.includes(cmd))) { + fail(`${rel} answers \`${cls}\` with \`${cmd}\`, and no workflow under .github/workflows/ runs it. A command nobody runs proves nothing: wire the stage, or change the answer to the form that is true.`); + } + } + } + if (row.form === 'manual') { + const marked = /defer:/i.test(text) && /upgrade-when:/i.test(text) + && new RegExp(`defer:[^]{0,400}?\\b${cls}\\b`, 'i').test(text); + if (!marked) { + fail(`${rel} answers \`${cls}\` with \`manual\` and carries no defer: marker naming it. A named manual check is allowed; an unmarked one is the silent drop the \`stack\` skill's platform route already refuses. Add a marker naming \`${cls}\`, with its ceiling and its upgrade-when.`); + } + } } } - // The design method's half of the same window. The detector is the first mechanical check + // The design method's half of the same question. The detector is the first mechanical check // this framework has on what an interface renders (spec 011), and it is the one gate whose // payload is deliberately absent from a clone: it is gitignored like a dependency. So the // question "does this project have an interface it judges with the method" is answered by // the tracked artifact the method writes, never by looking for the payload on disk. if (!existsSync(join(root, '.impeccable', 'config.json'))) return; - const wired = workflows.some((name) => lines(join(wfDir, name)).some(runsDetector)); - if (!wired) { + if (!live.some(runsDetector)) { fail(`.impeccable/config.json declares the design method for this project, but no workflow in .github/workflows/ runs its detector, so nothing mechanical looks at what this interface renders. Add the stage per the skill \`stack\` section 3 (\`npx -y impeccable@latest detect \`), and leave it running rather than commented: a stage nobody runs proves nothing.`); } }, diff --git a/checks/check-stack.test.mjs b/checks/check-stack.test.mjs index b3e3f9a..89e2e22 100644 --- a/checks/check-stack.test.mjs +++ b/checks/check-stack.test.mjs @@ -9,9 +9,35 @@ import { expectClean, expectFail, report } from './check-fixture.mjs'; // The manifest row keeps docs-manifest quiet, so only the gate under test speaks. const manifest = '# manifest\n\n| `state/STATE.md` | LIVE | state |\n| `standards/**` | LIVE | standards |\n'; +// A filled floor table, shaped the way docs/standards/TEMPLATE-STACK.md shapes it. The argument +// overrides one or more classes, so a test can say exactly which shape it is about. +const floor = (rows = {}) => { + const base = { + builds: ['command', '`npm run build`'], + behaves: ['command', '`npm test`'], + analyzed: ['command', '`npm run lint`'], + dependencies: ['command', '`npm audit`'], + secrets: ['not applicable', 'no product code here yet'], + renders: ['not applicable', 'this project ships no interface'], + ...rows, + }; + const body = Object.entries(base) + .map(([cls, [form, answer]]) => `| \`${cls}\` | the risk | ${form} | ${answer} |`).join('\n'); + return ['# TypeScript', '', '- Platform: no', '', '## The floor', '', + '| Class | The risk it covers | Form | Answer |', '|---|---|---|---|', body, '', '## Notes', ''].join('\n'); +}; + +// A stack is chosen, and its floor is answered in a way ARMED_CI below satisfies: one live +// command, the rest waived with a reason. Before S-02 this fixture carried no floor table at all, +// which the gate now reads as a contract that is absent rather than merely unfilled. const stack = ({ put }) => { put('docs/README.md', manifest); - put('docs/standards/typescript.md', '# TypeScript\n\n- Platform: no\n'); + put('docs/standards/typescript.md', floor({ + builds: ['not applicable', 'nothing to assemble in this fixture'], + behaves: ['not applicable', 'no product code in this fixture'], + analyzed: ['command', '`npm run typecheck`'], + dependencies: ['not applicable', 'no dependencies in this fixture'], + })); }; const PLACEHOLDER_CI = `name: ci @@ -103,4 +129,65 @@ expectClean('stack-gates-detector-alone-needs-no-stack-file', (fx) => { fx.put('.github/workflows/ci.yml', PLACEHOLDER_CI + detectStep); }); +// --- The floor contract: six classes answered, and every command actually running ------------ + +const FLOOR_CI = `name: ci +jobs: + gate: + steps: + - name: Groundwork checks + run: node checks/check.mjs + - name: Build + run: npm run build + - name: Tests + run: npm test + - name: Lint + run: npm run lint + - name: Audit + run: npm audit +`; + +const BARE_CI = `name: ci +jobs: + gate: + steps: + - name: Groundwork checks + run: node checks/check.mjs +`; + +const withFloor = (contract, ci = FLOOR_CI) => ({ put }) => { + put('docs/README.md', manifest); + put('docs/standards/typescript.md', contract); + put('.github/workflows/ci.yml', ci); +}; + +// THE HOLE THIS STORY EXISTS FOR. Deleting the commented placeholders was one of the two fixes the +// old gate's own message proposed, and it satisfied that gate while wiring nothing. Here the stack +// declares four commands and the workflow runs none of them. +expectFail('stack-gates', withFloor(floor(), BARE_CI)); + +// A class left blank is a class nobody decided about, which is the silence the contract refuses. +expectFail('stack-gates', withFloor(floor({ behaves: ['', ''] }))); + +// A declared stack with no floor table at all: the contract is absent, not merely unfilled. +expectFail('stack-gates', withFloor('# TypeScript\n\n- Platform: no\n')); + +// A command that exists only as a comment is a stage nobody runs. Same rule the design half +// already holds: a stage that is talked about is not a stage. +expectFail('stack-gates', withFloor(floor(), + `${BARE_CI} # - name: Build\n # run: npm run build\n - name: Tests\n run: npm test\n` + + ' - name: Lint\n run: npm run lint\n - name: Audit\n run: npm audit\n')); + +// `manual` is an allowed answer and a tracked one: without a defer: marker naming the class it is +// the silent drop the platform route already refuses. +expectFail('stack-gates', withFloor(floor({ behaves: ['manual', 'a scripted regression pass before release'] }))); + +// The three forms, all used honestly, and the gate goes quiet. The manual class carries its +// marker; the waived ones carry their reason. +expectClean('stack-gates-floor-answered', withFloor( + floor({ behaves: ['manual', 'a scripted regression pass before release'] }) + + '\n\n')); + report('stack-gate'); diff --git a/checks/drill.mjs b/checks/drill.mjs index 49930b0..983581c 100644 --- a/checks/drill.mjs +++ b/checks/drill.mjs @@ -170,6 +170,7 @@ export const STEPS = [ } for (const spec of numberedSpecs(ctx.copy)) rmSync(at('docs', 'specs', spec), { recursive: true }); rmSync(at('docs', 'specs', 'archive', '000-baseline'), { recursive: true, force: true }); + rmSync(at('docs', 'standards', 'node.md'), { force: true }); const pairs = [ [at('docs', 'product', 'TEMPLATE-VISION.md'), at('docs', 'product', 'VISION.md')], [at('docs', 'product', 'TEMPLATE-BRIEF.md'), at('docs', 'product', 'BRIEF.md')], @@ -201,6 +202,10 @@ export const STEPS = [ must(numberedSpecs(ctx.copy).length === 0, 'an in-flight spec folder survived the clearing'); must(!existsSync(at('docs', 'specs', 'archive', '000-baseline')), "Groundwork's own baseline survived the clearing"); + must(!existsSync(at('docs', 'standards', 'node.md')), + "Groundwork's own stack file survived the clearing, so the copy inherits a floor it did not choose"); + must(existsSync(at('docs', 'standards', 'TEMPLATE-STACK.md')), + 'the stack template went with it, leaving the copy no shape to write its own floor from'); const checks = node(ctx.copy, ['checks/check.mjs']); must(checks.status === 0, `check.mjs failed after the clearing:\n${checks.stdout}`); return `${pairs.length} files blanked, denylist emptied, gates still green`; diff --git a/docs/standards/GLOBAL.md b/docs/standards/GLOBAL.md index c107664..2f7fc93 100644 --- a/docs/standards/GLOBAL.md +++ b/docs/standards/GLOBAL.md @@ -58,7 +58,8 @@ generated by the `stack` skill; where they conflict, the stack file wins and mus - Validate all input at trust boundaries; encode all output for its destination. - Least privilege everywhere: tokens, DB users, file permissions, CI scopes. - Secrets in the environment or a vault, `.env.example` documents the shape. -- Dependencies audited in CI; known-vulnerable versions block delivery. +- Dependencies audited in CI; known-vulnerable versions block delivery. The `dependencies` class + of the floor table in this project's stack file is where that is answered and checked. ## Errors & observability (floor, all stacks) diff --git a/docs/standards/TEMPLATE-STACK.md b/docs/standards/TEMPLATE-STACK.md index 89da8ff..13b7c2b 100644 --- a/docs/standards/TEMPLATE-STACK.md +++ b/docs/standards/TEMPLATE-STACK.md @@ -1,8 +1,9 @@ # TEMPLATE: `` standards + 2). The floor table stays: `checks/check.mjs` reads it and refuses a class nobody answered. + Replace what is in , and delete the worked answers once your own are filled + in. --> ## The floor @@ -21,7 +22,10 @@ list. **Each row is answered or the gates do not read green.** Three answer forms, and no fourth: - **command** - what CI runs: a shell line, or this host's own task. It has to exist as a live - stage in a workflow, never as a comment. + stage in a workflow, never as a comment. The Answer cell holds the command in backticks and + nothing else in backticks, because every backticked span in it is read as a command that must be + running. Two commands answering one class both have to run. Anything you want to say about the + answer goes under the table, where the reader has room and the parser does not look. - **`not applicable`** - plus the reason. Use it when the class genuinely cannot apply here, never when it is merely inconvenient. - **`manual`** - plus the named check and who runs it, and a `defer:` marker at the site. This is diff --git a/docs/standards/node.md b/docs/standards/node.md new file mode 100644 index 0000000..4b98e0f --- /dev/null +++ b/docs/standards/node.md @@ -0,0 +1,31 @@ +# Node standards: Groundwork's own stack + +- **Stack:** Node (ES modules, no build step) · **Platform:** no · **Verified:** 2026-08-26 + +This framework is written in the stack it ships: plain `.mjs` modules and Markdown, run directly by +Node, with no dependencies at all. `GLOBAL.md` is the cross-stack floor and applies here unchanged; +this file adds only what is true of this stack. How the project is built and governed lives in +`AGENTS.md`, not here. + +## The floor + +| Class | The risk it covers | Form | Answer | +|---|---|---|---| +| `builds` | It does not assemble or deploy, and finds out in production | not applicable | Nothing is assembled: modules run directly under Node, and the one published artifact is a tracked HTML file | +| `behaves` | It does not do what it claims, and nothing says so before a user does | command | `node checks/drill.mjs` | +| `analyzed` | Defects a machine can see are shipped because no machine looked | command | `node checks/check.mjs` | +| `dependencies` | Third-party code arrives with known holes, unknown licences, or unrecorded | not applicable | There is no package manifest and no third-party code, so there is nothing to audit, licence or list in a bill of materials | +| `secrets` | Keys, tokens and passwords ship inside the product | command | `node checks/check.mjs` | +| `renders` | What a person actually sees is broken, unreadable or unusable | command | `npx -y impeccable@latest detect index.html` | + +On `behaves`: the drill unpacks a fresh copy, runs every gate suite inside it and walks it to a +governed first commit. The sixteen suites also run one by one in the gate job, ahead of the checks +they prove, so a suite that stops running is a red build rather than a quiet gap. + +On `analyzed`, and stated as a gap rather than dressed up as an answer: that command runs this +project's own static analysis - secret scanning, dead-code detection, file-size limits - and +nothing a linter or a type checker would catch. Swallowed errors, unused bindings and unhandled +rejections are found by review here, not by a machine. + +On `dependencies`: verifiable rather than asserted, and it stops being true the day a dependency is +added. The waiver is the reason to notice that day.