create-cmp-cli 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +3 -3
  2. package/llms.txt +1 -1
  3. package/package.json +1 -1
  4. package/packages/harness/package.json +1 -1
  5. package/packages/harness/src/approve.mjs +7 -0
  6. package/packages/harness/src/lib/approvals.mjs +44 -9
  7. package/packages/harness/src/lib/evidence-level.mjs +3 -1
  8. package/packages/harness/src/lib/feature-brief.mjs +88 -16
  9. package/packages/harness/src/lib/flight-recorder.mjs +47 -2
  10. package/packages/harness/src/lib/inputs-hash.mjs +9 -0
  11. package/packages/harness/src/lib/lane-narrator.mjs +97 -0
  12. package/packages/harness/src/lib/lane-runner.mjs +173 -0
  13. package/packages/harness/src/lib/plan.mjs +466 -0
  14. package/packages/harness/src/lib/receipt-validate.mjs +4 -1
  15. package/packages/harness/src/lib/spec-coverage.mjs +35 -2
  16. package/packages/harness/src/lib/step-cache.mjs +1 -1
  17. package/packages/harness/src/lib/step-outcomes.mjs +123 -0
  18. package/packages/harness/src/lib/steps-cmp.mjs +1275 -0
  19. package/packages/harness/src/lib/walk.mjs +262 -21
  20. package/packages/harness/src/plan.mjs +64 -0
  21. package/packages/harness/src/receipt-check.mjs +59 -1
  22. package/packages/harness/src/verify.mjs +115 -1197
  23. package/packages/harness/src/walk-status.mjs +37 -1
  24. package/packages/receipts/src/inputs-hash.mjs +9 -0
  25. package/packages/receipts/src/receipt-validate.mjs +4 -1
  26. package/src/commands/doctor.mjs +26 -0
  27. package/src/lib/project-doctor.mjs +37 -0
  28. package/template/CLAUDE.md +73 -9
  29. package/template/gitignore +10 -0
  30. package/template/qa/approve.mjs +7 -0
  31. package/template/qa/lib/approvals.mjs +44 -9
  32. package/template/qa/lib/evidence-level.mjs +3 -1
  33. package/template/qa/lib/feature-brief.mjs +88 -16
  34. package/template/qa/lib/flight-recorder.mjs +47 -2
  35. package/template/qa/lib/inputs-hash.mjs +9 -0
  36. package/template/qa/lib/lane-narrator.mjs +97 -0
  37. package/template/qa/lib/lane-runner.mjs +173 -0
  38. package/template/qa/lib/plan.mjs +466 -0
  39. package/template/qa/lib/receipt-validate.mjs +4 -1
  40. package/template/qa/lib/spec-coverage.mjs +35 -2
  41. package/template/qa/lib/step-cache.mjs +1 -1
  42. package/template/qa/lib/step-outcomes.mjs +123 -0
  43. package/template/qa/lib/steps-cmp.mjs +1275 -0
  44. package/template/qa/lib/walk.mjs +262 -21
  45. package/template/qa/plan.mjs +64 -0
  46. package/template/qa/receipt-check.mjs +59 -1
  47. package/template/qa/verify.mjs +115 -1197
  48. package/template/qa/walk-status.mjs +37 -1
  49. package/template/specs/README.md +26 -0
@@ -19,8 +19,41 @@ import { fileURLToPath } from "node:url";
19
19
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
20
  const args = process.argv.slice(2);
21
21
 
22
+ /**
23
+ * The hook's stdin, parsed — UserPromptSubmit delivers {prompt, ...} as JSON.
24
+ * Bounded read, fail-soft: no stdin / non-JSON / no prompt -> null. Only the
25
+ * --inject path consumes this (the statusline gets no stdin and must not wait
26
+ * on one).
27
+ */
28
+ async function readHookStdin() {
29
+ if (process.stdin.isTTY) return null;
30
+ try {
31
+ let raw = "";
32
+ for await (const chunk of process.stdin) {
33
+ raw += chunk;
34
+ if (raw.length > 1_000_000) break; // a prompt is never this — stop reading, keep what we have
35
+ }
36
+ const parsed = JSON.parse(raw);
37
+ return parsed && typeof parsed === "object" ? parsed : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
22
43
  try {
23
44
  const { deriveWalks, renderStatusline, renderCard, renderInject } = await import("./lib/walk.mjs");
45
+
46
+ // Tier 1 of the chain (studio-drive-mode): record the human's own prompt
47
+ // BEFORE deriving, so this very inject already reflects the new request.
48
+ // Machinery-owned — the words are the hook's, never the agent's.
49
+ if (args.includes("--inject")) {
50
+ const hook = await readHookStdin();
51
+ if (hook && typeof hook.prompt === "string") {
52
+ const { recordRequest } = await import("./lib/plan.mjs");
53
+ recordRequest(ROOT, hook.prompt);
54
+ }
55
+ }
56
+
24
57
  const data = deriveWalks(ROOT);
25
58
 
26
59
  if (args.includes("--json")) {
@@ -43,7 +76,10 @@ try {
43
76
  } else if (data.walks.length === 0 && data.arrivals.length === 0) {
44
77
  process.stdout.write("No open walks. Every accepted feature's brief is its doc-of-record.\n");
45
78
  } else {
46
- for (const w of data.walks) process.stdout.write(`${renderCard(w)}\n\n`);
79
+ const { renderChain } = await import("./lib/plan.mjs");
80
+ const chainText = renderChain(data.chain);
81
+ if (chainText !== "") process.stdout.write(`${chainText}\n\n`);
82
+ for (const w of data.walks) process.stdout.write(`${renderCard(w, data)}\n\n`);
47
83
  for (const a of data.arrivals)
48
84
  process.stdout.write(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}\n`);
49
85
  }
@@ -48,7 +48,16 @@ export const VERIFIED_SURFACE = [
48
48
  // bookkeeping about a commit that already happened, so appending a record
49
49
  // must never invalidate a receipt for a tree whose code did not change
50
50
  // (approvals.log.jsonl's principle, applied to audits).
51
+ // qa/.request.json and qa/.plan.json are the live chain's EPHEMERAL state
52
+ // (studio-drive-mode): the request file is rewritten on EVERY user prompt by
53
+ // the UserPromptSubmit hook, so hashing either would invalidate the receipt
54
+ // the moment the human speaks. They are also gitignored on fresh scaffolds,
55
+ // but the exclusion here is the load-bearing one — upgraded apps keep their
56
+ // own .gitignore, which never learns new entries.
51
57
  const EXCLUDED_PREFIXES = [
58
+ "qa/.plan.json",
59
+ "qa/.request.json",
60
+ "qa/.plan-history.jsonl",
52
61
  "qa/evidence",
53
62
  "qa-artifacts",
54
63
  "qa/comments.json",
@@ -134,7 +134,10 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
134
134
  if (!steps || steps.length === 0) {
135
135
  return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
136
136
  }
137
- const executed = steps.filter((s) => s && s.verdict !== "SKIP");
137
+ // Executed = produced a verdict about the tree. SKIP did not try; ERROR
138
+ // tried and could not (a deadline, zero tests, a throw) — neither measured
139
+ // anything, so neither counts toward "this lane verified something".
140
+ const executed = steps.filter((s) => s && s.verdict !== "SKIP" && s.verdict !== "ERROR");
138
141
  if (executed.length === 0) {
139
142
  return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
140
143
  }
@@ -10,6 +10,7 @@
10
10
  // ksp.useKSP2=true, wire the walk into .claude/settings.json); everything else
11
11
  // prints the exact manual step.
12
12
 
13
+ import crypto from "node:crypto";
13
14
  import fs from "node:fs";
14
15
  import os from "node:os";
15
16
  import path from "node:path";
@@ -132,6 +133,30 @@ export function gatherWalkInputs(projectDir) {
132
133
  };
133
134
  }
134
135
 
136
+ /**
137
+ * The studio console's registry record for this app, when one exists — a
138
+ * CROSS-PACKAGE CONTRACT with the inspector's preview-service.mjs
139
+ * (consoleRegistryPath) and the harness's walk.mjs (consoleState):
140
+ * sha1(resolved projectDir).slice(0,12) keys `cmp-console-<key>.json` in
141
+ * os.tmpdir(), fields {pid, url}. pid-liveness only — no HTTP.
142
+ */
143
+ export function gatherConsoleInputs(projectDir) {
144
+ try {
145
+ const key = crypto.createHash("sha1").update(path.resolve(projectDir)).digest("hex").slice(0, 12);
146
+ const rec = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `cmp-console-${key}.json`), "utf8"));
147
+ if (!rec || typeof rec.pid !== "number") return null;
148
+ let pidAlive = true;
149
+ try {
150
+ process.kill(rec.pid, 0);
151
+ } catch (err) {
152
+ pidAlive = Boolean(err && err.code === "EPERM");
153
+ }
154
+ return { pidAlive, url: typeof rec.url === "string" ? rec.url : null };
155
+ } catch {
156
+ return null; // no record — never started, or stopped cleanly
157
+ }
158
+ }
159
+
135
160
  /** Gather filesystem/env inputs for the pure diagnosis. */
136
161
  export function gatherProjectInputs(projectDir) {
137
162
  const toml = readIfExists(path.join(projectDir, "gradle", "libs.versions.toml"));
@@ -174,6 +199,7 @@ export function gatherProjectInputs(projectDir) {
174
199
  inspectorHits,
175
200
  inspectorCatalog,
176
201
  walk: gatherWalkInputs(projectDir),
202
+ consoleRecord: gatherConsoleInputs(projectDir),
177
203
  };
178
204
  }
179
205
 
@@ -40,6 +40,10 @@ export const DISK_WARN_BYTES = 3 * GIB;
40
40
  * promptHook:boolean}|null} [input.walk] the walk's wiring: is
41
41
  * qa/walk-status.mjs installed, and does .claude/settings.json actually
42
42
  * INVOKE it (statusLine + UserPromptSubmit)? null = skip the check.
43
+ * @param {{pidAlive:boolean, url:(string|null)}|null} [input.consoleRecord] the studio
44
+ * console's tmp-dir registry record for this app, when one exists: is its
45
+ * process still alive, and at what URL? null = no record (never started, or
46
+ * stopped cleanly — nothing to say).
43
47
  * @param {{catalog:string, theme:string}|null} [input.inspectorCatalog] the stamped
44
48
  * InspectorCatalog.kt content + concatenated theme sources (Tokens.kt/Theme.kt) for
45
49
  * the declared-token drift tripwire; null = skip.
@@ -60,6 +64,7 @@ export function diagnoseProject(input) {
60
64
  inspectorHits = null,
61
65
  inspectorCatalog = null,
62
66
  walk = null,
67
+ consoleRecord = null,
63
68
  } = input;
64
69
 
65
70
  // --- version catalog ------------------------------------------------------
@@ -361,6 +366,38 @@ export function diagnoseProject(input) {
361
366
  }
362
367
  }
363
368
 
369
+ // The studio console's liveness (walk-legibility L6c). A registry record with
370
+ // a dead pid means the console CRASHED — a clean stop removes the record — and
371
+ // the human's window silently disappeared. The statusline appends "console
372
+ // down" live; doctor is the once-over that says the same thing with the fix.
373
+ if (consoleRecord !== null) {
374
+ if (consoleRecord.pidAlive) {
375
+ findings.push({
376
+ id: "console-liveness",
377
+ level: "ok",
378
+ title: "The studio console is running",
379
+ detail: `A console is registered for this app${consoleRecord.url ? ` at ${consoleRecord.url}` : ""} and its process is alive.`,
380
+ });
381
+ } else {
382
+ findings.push({
383
+ id: "console-liveness",
384
+ level: "warn",
385
+ title: "The studio console crashed",
386
+ detail:
387
+ "A console registry record exists for this app but its process is gone — the " +
388
+ "window died without a clean stop, and every surface that leads with the console " +
389
+ "is now pointing at nothing.",
390
+ fix: {
391
+ auto: false,
392
+ description:
393
+ "Reconnect the cmp-inspector MCP (it ensures a resident console at session " +
394
+ "start), call the preview tool, or start one by hand: " +
395
+ "node inspector/mcp/bin/console.mjs <projectDir> (detached: nohup … &).",
396
+ },
397
+ });
398
+ }
399
+ }
400
+
364
401
  return findings;
365
402
  }
366
403
 
@@ -38,6 +38,13 @@ it; CI still enforces it).
38
38
  New behavior begins as a spec clause in `specs/<feature>.spec.md`: Given/When/Then with a
39
39
  stable id (see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
40
40
  then implement. Durable tests cite their clause (`// SPEC: HOME-02`).
41
+
42
+ **A clause about device behavior must say so.** A citation proves a test *exists*; it cannot
43
+ prove that test could ever *observe* the promise. Add `[tier: device]` (or `[tier: e2e]`)
44
+ after the id when the claim is about OS facts a host JVM cannot see — lifecycle, alarms,
45
+ notifications, permissions, real navigation. `specCoverage` then requires a citation from
46
+ `androidInstrumentedTest` or `qa/e2e` and FAILS without one, rather than accepting a
47
+ desktop test that is structurally blind to the claim.
41
48
  [`specs/app-base.spec.md`](./specs/app-base.spec.md) states the architecture and shell
42
49
  invariants the conformance gates enforce.
43
50
 
@@ -90,8 +97,15 @@ the tree. The governed `architecture` artifact (below) hashes the document along
90
97
  if the test itself is wrong, say so in your summary and justify the change.
91
98
 
92
99
  **Platform behavior tests live in `composeApp/src/androidInstrumentedTest`** — when a
93
- feature touches alarms, notifications, lock-screen intents, or audio routing, its behavior
94
- test goes there, because no desktop tier can see those OS facts. Assertion helpers:
100
+ feature touches alarms, notifications, lock-screen intents, audio routing, **or app/process
101
+ lifecycle** (cold start vs warm resume, "once per process start", process death and
102
+ restore, `ON_STOP`/`ON_START`), its behavior test goes there, because no desktop tier can
103
+ see those OS facts. A desktop Compose test has no process lifecycle *at all*, so a claim
104
+ about one is unobservable there by construction — and `ProcessControl` below is the organ
105
+ that puts the device into the state such a claim is about. **Declare it on the clause**:
106
+ `- **MOTION-13** [tier: device] — Given a cold start, …`. The lane's `specCoverage` then
107
+ FAILS unless a test from a tier that can actually see it cites the clause, instead of
108
+ accepting a citation from a tier that cannot. Assertion helpers:
95
109
  `NotificationAsserts`, `AlarmAsserts`, `SystemState`. **Runtime state control** — put the
96
110
  device into the state your claim is about, instead of waiting for it: `TimeWarp` (clock,
97
111
  timezone), `DozeControl` (forced idle), `PermissionControl`, `ProcessControl`,
@@ -198,6 +212,20 @@ understood the change to be, which lane it takes, and why, before any tool runs.
198
212
  can overrule the lane in a word; a silent route is a routing error even when the lane was
199
213
  right.
200
214
 
215
+ **Grill before the brief** (the `grill-me` plugin skill; the rule holds without the plugin):
216
+ on the brief lane, after the triage restatement and before a word of the brief is drafted,
217
+ settle the load-bearing questions. Read what the repo already answers first — a signed brief
218
+ or spec is a CLOSED decision: cite it, never re-ask it. Then ask the frontier of unsettled
219
+ decisions as a numbered list, at most five per round, each with why it matters and a
220
+ recommended answer — and WAIT for the answers before anything else. Stop when no remaining
221
+ question would change the work; three rounds is the ceiling (more means the request needs
222
+ splitting). Answers land in the brief — settled calls become **Decisions** with their why,
223
+ the human's own calls the **Open decisions** section; the brief's signature closes them. The
224
+ direct lane is not grilled (one inline question at most, only when the restatement cannot
225
+ be made unambiguous); a bug fix or an emergency fix, never. While the grill is open, the
226
+ chain's first step reads `settle the open questions` (declare it before the first round;
227
+ re-declare when the answers reshape the steps).
228
+
201
229
  **Brief lane** — when the change carries **decisions a future contributor could plausibly
202
230
  "simplify" away** ("the day boundary is configurable, default 04:00 — not midnight") OR
203
231
  **blast radius into other governed artifacts**. After naming the lane:
@@ -317,21 +345,56 @@ evidence attached"). `node qa/walk-status.mjs` derives the live position; a
317
345
  UserPromptSubmit hook injects it every prompt. **Render the injected state — never
318
346
  your memory of it.**
319
347
 
320
- **At kickoff** (with the triage restatement): print the itinerary —
348
+ **At kickoff** (with the triage restatement): print the itinerary — and DECLARE it as
349
+ the live chain, so the studio's Drive strip and the statusline's readers see the same
350
+ steps you just printed:
321
351
 
322
352
  Navigation redesign — the journey (brief lane)
323
353
  Decide → Design → Contract → Build → Prove → Sign-off
324
354
  Stops for you: 3 (Decide — now · Contract · Sign-off). Build and Prove never stop for you.
325
355
  First stop is now: 2 open decisions below.
326
356
 
327
- **While working — quiet:** one line per stage transition, nothing per-file. The
328
- statusline carries the position continuously; do not repeat it in prose.
329
-
330
- **At every human gate — loud:** a full stop card, never a bare question:
357
+ ```bash
358
+ node qa/plan.mjs --set "sign the brief | draft screens | agree the promises | build | full check | your sign-off" --title "navigation redesign"
359
+ ```
331
360
 
332
- YOUR TURN <feature> · stage 3 of 6: Contract
361
+ **The chain is an offer, not an announcement** (drive-narration N6): show the declared
362
+ steps in your first reply and invite the reshape in one breath — "say the word and I'll
363
+ reorder" — then start work immediately; the chain gates nothing, so the offer never
364
+ blocks. If the human redirects, re-declare (`--set` again) without ceremony: their
365
+ reshape IS the new chain.
366
+
367
+ **The chain stays current** — this is part of the contract, not a nicety: advance it
368
+ with `node qa/plan.mjs --step N` as each step lands and `--done` when the request
369
+ lands (closing writes the request's line into the local trail the studio's Recent
370
+ requests fold shows). The current request itself is recorded mechanically (the
371
+ per-prompt hook), the steps are yours to declare, and every surface shows the
372
+ declaration's age — a stale chain reads as stale to the human watching the studio,
373
+ which is worse than no chain. While the full check runs, the chain's observed line
374
+ narrates the lane's own position (step, elapsed, usual cost) — quote THAT, never an
375
+ estimate. The chain gates nothing; the walk stays the truth for doneness.
376
+
377
+ **The studio is a standing check:** every injected context opens with a `[studio: …]`
378
+ line. If it says DOWN or not running, restore it before proceeding — call the
379
+ cmp-inspector `preview { projectDir }` tool (it starts a detached resident console
380
+ that survives the session) — or, if the tools are absent, tell the human once. A
381
+ missing window is a fault to heal, never something to work silently past.
382
+
383
+ **While working — the header, then quiet:** open EVERY reply with the walk's one-line
384
+ header — the exact `[chat header]` line the per-prompt inject delivers. Paste it
385
+ verbatim, never compose it: it is the derivation's own string, so it cannot drift, and
386
+ it persists in the transcript, which the statusline beneath the input box never does.
387
+ After the header: one line per stage transition, nothing per-file. Stages carry their
388
+ plain-words gloss on first mention ("Contract — agreeing what it promises"); quote the
389
+ lane's cost only from the measured figure in the injected card, never an estimate.
390
+
391
+ **At every human gate — loud:** a full stop card, never a bare question — and the
392
+ easiest act leads:
393
+
394
+ ■ YOUR TURN — <feature> · stage 3 of 6: Contract — agreeing what it promises
333
395
  <what it is, in plain words — two lines maximum>
334
- <exactly what to do: the command, or the studio tab, or "reply approve">
396
+ Easiest: the studio console at <url from the injected card> the row carries the button.
397
+ → CLI fallback: <the command> (or "reply approve" when no console is up)
335
398
  After this: <the remaining stages, and which ones stop for the human>
336
399
 
337
400
  **Arrivals:** work that belongs to no open walk (undeclared drift, a harness
@@ -455,6 +518,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
455
518
  | `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
456
519
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
457
520
  | `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
521
+ | `node qa/verify.mjs --profile nightly` | Scheduled stage: everything `ci` proves with the determinism probe forced on. Proves the harness, never a change — its receipt (`stage: "nightly"`) is refused as done-evidence, exactly like `--fast`. Schedule it; never wait on it |
458
522
  | `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
459
523
  | `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
460
524
  | `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
@@ -34,3 +34,13 @@ xcuserdata/
34
34
  # ignored by git. Delete them once you have reviewed the upgrade's diff.
35
35
  *.bak-upgrade
36
36
  *.cmp-new
37
+
38
+ # The live chain's ephemeral state (studio-drive-mode): the current request
39
+ # (rewritten by the UserPromptSubmit hook on every prompt) and the agent's
40
+ # declared step plan. Per-session windshield, not project history — and also
41
+ # hard-excluded from the receipt's hashed input surface (qa/lib/inputs-hash.mjs).
42
+ qa/.request.json
43
+ qa/.plan.json
44
+ # The closed-chain trail (drive-narration N5): local because it carries raw
45
+ # human prompts — the committed journal for lane runs stays qa/flight-recorder.jsonl.
46
+ qa/.plan-history.jsonl
@@ -201,9 +201,16 @@ if (reopenFeatureFlagIdx !== -1) {
201
201
  console.error(`error: ${result.reason}`);
202
202
  process.exit(1);
203
203
  }
204
+ const inScope = result.reopened.length + result.skipped.length + (result.stillSigned ?? []).length;
204
205
  console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
206
+ console.log(` ${inScope} in scope · ${result.reopened.length} reopened · ${(result.stillSigned ?? []).length} still signed`);
205
207
  for (const id of result.reopened) console.log(` ↺ ${id}`);
206
208
  for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
209
+ // The declared blast radius is reported, not walked back: a signature is
210
+ // demanded again only if the change actually moves the bytes it covers.
211
+ for (const t of result.stillSigned ?? []) {
212
+ console.log(` ✓ ${t.id} still signed (${t.status}${t.hash ? ` @${t.hash}` : ""}) — re-signature demanded only if it changes; the hash enforces that`);
213
+ }
207
214
  process.exit(0);
208
215
  }
209
216
 
@@ -1063,13 +1063,36 @@ export function reopenFeature(root, name, options = {}) {
1063
1063
  return { ok: false, reason: `unknown feature "${name}" — known briefs: ${briefs.join(", ") || "(none)"}` };
1064
1064
  }
1065
1065
  const derived = deriveAllFeatures(root).find((d) => d.name === name);
1066
- const set = [briefId, `feature-spec:${name}`, `${FEATURE_DESIGN_PREFIX}${name}`, ...(derived ? derived.touches : [])];
1066
+ // The spec side of the family follows the brief's own pairing (a multi-spec
1067
+ // brief reopens every spec its promises live in), defaulting to the name.
1068
+ const specIds = (derived?.specNames ?? [name]).map((n) => `feature-spec:${n}`);
1069
+ // WHAT A FEATURE REOPEN WALKS BACK (evidence-economics S5, aligning this
1070
+ // function with CHANGE-FLOW-DESIGN.md §"touches": "hashes enforce,
1071
+ // declaration lets the console tell as-planned from undeclared blast").
1072
+ //
1073
+ // reopened the brief, its declared spec(s), and its design when the
1074
+ // brief declares a UI surface — the documents the change
1075
+ // will AMEND. Their signatures are walked back on purpose.
1076
+ // stillSigned the declared `touches`. Before this, every one of them was
1077
+ // reopened too, and every one came back byte-identical:
1078
+ // twelve signatures for zero changes (design-system
1079
+ // d8fbdce8 → d8fbdce8). An `approved` artifact is, by
1080
+ // definition, one whose bytes still match what was signed —
1081
+ // so reopening it re-asks a question the hash has already
1082
+ // answered. Worse than wasted: it trains the signer to
1083
+ // approve without reading, the exact habit approvals exist
1084
+ // to prevent. They stay signed. If the change DOES move one,
1085
+ // its hash flips it to `changed` and demands a fresh
1086
+ // signature — the enforcement the doc always assigned to the
1087
+ // hash, not to this verb.
1088
+ const amendSet = [briefId, ...specIds, ...(derived?.screens ? [`${FEATURE_DESIGN_PREFIX}${name}`] : [])];
1089
+ const touchSet = (derived ? derived.touches : []).filter((id) => !amendSet.includes(id));
1067
1090
  const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
1068
1091
  const reopened = [];
1069
1092
  const skipped = [];
1070
- for (const id of [...new Set(set)]) {
1093
+ for (const id of [...new Set(amendSet)]) {
1071
1094
  const live = byId.get(id);
1072
- if (!live) continue; // declared touch that resolves to no governed artifact — nothing to reopen
1095
+ if (!live) continue; // resolves to no governed artifact — nothing to reopen
1073
1096
  if (live.status !== "approved") {
1074
1097
  skipped.push({ id, status: live.status });
1075
1098
  continue;
@@ -1078,15 +1101,21 @@ export function reopenFeature(root, name, options = {}) {
1078
1101
  if (result.ok) reopened.push(id);
1079
1102
  else skipped.push({ id, status: `refused: ${result.reason}` });
1080
1103
  }
1104
+ const stillSigned = [];
1105
+ for (const id of [...new Set(touchSet)]) {
1106
+ const live = byId.get(id);
1107
+ if (!live) continue;
1108
+ stillSigned.push({ id, status: live.status, hash: typeof live.hash === "string" ? live.hash.slice(0, 8) : null });
1109
+ }
1081
1110
  if (reopened.length === 0) {
1082
1111
  return {
1083
1112
  ok: false,
1084
1113
  reason:
1085
- `nothing in "${name}"'s set is currently approved — there is no signature to walk back. ` +
1086
- `Set: ${[...new Set(set)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
1114
+ `nothing in "${name}"'s amend set is currently approved — there is no signature to walk back. ` +
1115
+ `Set: ${[...new Set(amendSet)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
1087
1116
  };
1088
1117
  }
1089
- return { ok: true, feature: name, reopened, skipped };
1118
+ return { ok: true, feature: name, reopened, skipped, stillSigned };
1090
1119
  }
1091
1120
 
1092
1121
  // ── The verify-lane gate ─────────────────────────────────────────────────────
@@ -1274,7 +1303,12 @@ export function getFeatureBoard(root) {
1274
1303
  // feature-spec:* that is still signed must be reopened and amended, and the
1275
1304
  // step says so by name — that is what the human's signature set in motion.
1276
1305
  const deriveNextStep = (d, phase) => {
1277
- const specArtifact = byId.get(`feature-spec:${d.name}`);
1306
+ // The brief's PAIRED specs (feature-brief.mjs pairedSpecNames — the one
1307
+ // pairing function): a multi-spec brief waits on ALL of them being
1308
+ // signed, and its contract step names each one still waiting.
1309
+ const specArtifacts = (d.specNames ?? [d.name])
1310
+ .map((n) => byId.get(`feature-spec:${n}`))
1311
+ .filter(Boolean);
1278
1312
  const designArtifact = byId.get(`${FEATURE_DESIGN_PREFIX}${d.name}`) ?? null;
1279
1313
  const declaredSpecAmendments = d.touches
1280
1314
  .filter((id) => id.startsWith("feature-spec:") && byId.get(id)?.status === "approved")
@@ -1343,8 +1377,9 @@ export function getFeatureBoard(root) {
1343
1377
  // phase === "approved": building — which part of the loop is open?
1344
1378
  if (!d.specExists || d.total === 0)
1345
1379
  return { key: "contract", owner: "agent drafts → human signs", label: `contract: write the clauses in ${d.specRel}${amendNote}` };
1346
- if (specArtifact && specArtifact.status !== "approved")
1347
- return { key: "sign-spec", owner: "human", label: `sign the contract (feature-spec:${d.name})${amendNote}` };
1380
+ const unsignedSpecs = specArtifacts.filter((a) => a.status !== "approved");
1381
+ if (unsignedSpecs.length > 0)
1382
+ return { key: "sign-spec", owner: "human", label: `sign the contract (${unsignedSpecs.map((a) => a.id).join(", ")})${amendNote}` };
1348
1383
  if (d.covered < d.total)
1349
1384
  return { key: "build", owner: "agent", label: `build & cite: ${d.total - d.covered} clause(s) have no citing test yet` };
1350
1385
  return { key: "prove", owner: "agent", label: "prove: run node qa/verify.mjs so the receipt attests this tree" };
@@ -84,7 +84,9 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
84
84
  export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
85
85
  if (mode === "fast") return null; // the inner loop derives no rung — ever
86
86
  const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
87
- if (steps.some((s) => s.verdict === "FAIL")) return null; // a failed lane has no rung
87
+ // A failed lane has no rung and a lane with a step that could not run
88
+ // (ERROR) has none either: a rung is evidence, and "could not check" is not.
89
+ if (steps.some((s) => s.verdict === "FAIL" || s.verdict === "ERROR")) return null;
88
90
  const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
89
91
 
90
92
  if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
@@ -7,8 +7,9 @@
7
7
  // docs/features/ is a governed `feature-brief:<name>` artifact, hashed and
8
8
  // signed like anything else, approved BEFORE the feature is built. (Harness
9
9
  // design standards stay in docs/proposals/ — different directory, different
10
- // meaning.) `<name>` matches the feature's spec: docs/features/meal.md pairs
11
- // with specs/meal.spec.md.
10
+ // meaning.) `<name>` pairs with the feature's spec by default
11
+ // specs/<name>.spec.md, overridable by the brief itself when its promises
12
+ // genuinely live in several spec files (see pairedSpecNames).
12
13
  //
13
14
  // The brief carries at most ONE machine-read block, and it declares — it never
14
15
  // gates:
@@ -163,30 +164,90 @@ export function briefSections(markdown) {
163
164
 
164
165
  /**
165
166
  * A brief's declarations: blast radius (`touches`), UI surface (`screens`),
166
- * and the reachability exemption (`unrouted` — FI-7's escape hatch: a screen
167
- * intentionally not wired into the navigation graph yet). A missing block, or
168
- * one without a field, declares nothing legal and common. A block that IS
169
- * present but malformed is surfaced as `error`: a doc that tried to declare
170
- * and failed should say so, not read as "declares nothing".
167
+ * the reachability exemption (`unrouted` — FI-7's escape hatch: a screen
168
+ * intentionally not wired into the navigation graph yet), and the paired
169
+ * spec files (`specs`walk-legibility L1: spec NAMES, no path/extension;
170
+ * `"specs": ["catalog", "entry-editing"]`). A missing block, or one without a
171
+ * field, declares nothing legal and common. A block that IS present but
172
+ * malformed is surfaced as `error`: a doc that tried to declare and failed
173
+ * should say so, not read as "declares nothing".
171
174
  * @param {string} markdown
172
- * @returns {{touches: string[], screens: boolean, unrouted: boolean, error: (string|null)}}
175
+ * @returns {{touches: string[], screens: boolean, unrouted: boolean, specs: string[], error: (string|null)}}
173
176
  */
174
177
  export function parseFeatureBlock(markdown) {
175
178
  const m = typeof markdown === "string" ? markdown.match(FEATURE_FENCE_RE) : null;
176
- if (!m) return { touches: [], screens: false, unrouted: false, error: null };
179
+ if (!m) return { touches: [], screens: false, unrouted: false, specs: [], error: null };
177
180
  let parsed;
178
181
  try {
179
182
  parsed = JSON.parse(m[1]);
180
183
  } catch (err) {
181
- return { touches: [], screens: false, unrouted: false, error: `cmp:feature block is not valid JSON — ${err.message}` };
184
+ return { touches: [], screens: false, unrouted: false, specs: [], error: `cmp:feature block is not valid JSON — ${err.message}` };
182
185
  }
183
186
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
184
- return { touches: [], screens: false, unrouted: false, error: "cmp:feature must be a JSON object" };
187
+ return { touches: [], screens: false, unrouted: false, specs: [], error: "cmp:feature must be a JSON object" };
185
188
  }
186
189
  const touches = Array.isArray(parsed.touches)
187
190
  ? parsed.touches.filter((t) => typeof t === "string" && t.trim() !== "")
188
191
  : [];
189
- return { touches, screens: parsed.screens === true, unrouted: parsed.unrouted === true, error: null };
192
+ // `specs` entries are normalized to bare names ("specs/catalog.spec.md" and
193
+ // "catalog" both mean specs/catalog.spec.md) — declaring in either form is
194
+ // fine; storing one form keeps every consumer's arithmetic identical.
195
+ const specs = Array.isArray(parsed.specs)
196
+ ? [
197
+ ...new Set(
198
+ parsed.specs
199
+ .filter((s) => typeof s === "string" && s.trim() !== "")
200
+ .map((s) => s.trim().replace(/^specs\//, "").replace(/\.spec\.md$/, "")),
201
+ ),
202
+ ]
203
+ : [];
204
+ return { touches, screens: parsed.screens === true, unrouted: parsed.unrouted === true, specs, error: null };
205
+ }
206
+
207
+ /**
208
+ * The spec files a brief's promises live in — THE pairing function
209
+ * (walk-legibility L1). One definition, consumed by the board derivation, the
210
+ * walk, and (through them) the console, so no surface can pair differently.
211
+ * Precedence:
212
+ * 1. the cmp:feature block's `"specs": [...]` — the explicit declaration
213
+ * 2. the brief's `**Spec:**` paragraph — every `specs/<name>.spec.md`
214
+ * reference in it (the form briefs already carry for human readers)
215
+ * 3. the filename default: `specs/<name>.spec.md`
216
+ * Before this existed, a brief whose blast radius genuinely spans two specs
217
+ * (catalog-and-editing, showcase 2026-08-26) derived as "still awaiting a
218
+ * contract" forever — a standing false instruction on the primary surface
219
+ * that invites an agent to write a second definition of signed behavior.
220
+ * @param {string} markdown the brief's full text
221
+ * @param {string} name the brief's name (docs/features/<name>.md)
222
+ * @param {{specs?: string[]}} [block] a parseFeatureBlock result, if the
223
+ * caller already has one (avoids re-parsing; same answer either way)
224
+ * @returns {string[]} spec names, e.g. ["catalog", "entry-editing"]
225
+ */
226
+ export function pairedSpecNames(markdown, name, block) {
227
+ const declared = (block ?? parseFeatureBlock(markdown)).specs ?? [];
228
+ if (declared.length > 0) return declared;
229
+ const fromHeader = specHeaderNames(markdown);
230
+ if (fromHeader.length > 0) return fromHeader;
231
+ return [name];
232
+ }
233
+
234
+ /**
235
+ * Every `specs/<name>.spec.md` referenced in the brief's `**Spec:**`
236
+ * paragraph — the line starting `**Spec:**` through the next blank line, so
237
+ * later prose that merely MENTIONS a spec path never redirects the pairing.
238
+ */
239
+ function specHeaderNames(markdown) {
240
+ if (typeof markdown !== "string") return [];
241
+ const lines = markdown.split("\n");
242
+ const start = lines.findIndex((l) => /^\*\*Spec:?\*\*/.test(l.trim()));
243
+ if (start === -1) return [];
244
+ const para = [];
245
+ for (let i = start; i < lines.length && lines[i].trim() !== ""; i++) para.push(lines[i]);
246
+ const out = [];
247
+ for (const m of para.join("\n").matchAll(/specs\/([A-Za-z0-9_-]+)\.spec\.md/g)) {
248
+ if (!out.includes(m[1])) out.push(m[1]);
249
+ }
250
+ return out;
190
251
  }
191
252
 
192
253
  /**
@@ -262,12 +323,21 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
262
323
  } catch {
263
324
  readable = false;
264
325
  }
265
- const block = readable ? parseFeatureBlock(markdown) : { touches: [], screens: false, error: `${brief.rel} could not be read` };
326
+ const block = readable
327
+ ? parseFeatureBlock(markdown)
328
+ : { touches: [], screens: false, specs: [], error: `${brief.rel} could not be read` };
266
329
 
267
- const specRel = `specs/${brief.name}.spec.md`;
268
- const specExists = fs.existsSync(path.join(root, specRel));
330
+ // The paired specs (walk-legibility L1): usually one, by filename; a brief
331
+ // may name several. Clauses concatenate in declaration order — "done" means
332
+ // every live clause across ALL of them is cited.
333
+ const specNames = pairedSpecNames(markdown, brief.name, block);
334
+ const specRels = specNames.map((n) => `specs/${n}.spec.md`);
335
+ const specExists = specRels.every((rel) => fs.existsSync(path.join(root, rel)));
336
+ const specRel = specRels.join(" + ");
269
337
  const citedIds = new Set((pre.citations ?? scanCitations(root)).map((t) => t.id));
270
- const clauses = clausesOfSpec(root, specRel).map((c) => ({ ...c, cited: citedIds.has(c.id) }));
338
+ const clauses = specRels
339
+ .flatMap((rel) => clausesOfSpec(root, rel))
340
+ .map((c) => ({ ...c, cited: citedIds.has(c.id) }));
271
341
  const live = clauses.filter((c) => !c.withdrawn);
272
342
  const covered = live.filter((c) => c.cited).length;
273
343
 
@@ -286,6 +356,8 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
286
356
  // rung's only mechanical signal (see countEdgeCases).
287
357
  edgeCases: readable ? countEdgeCases(markdown) : 0,
288
358
  specRel,
359
+ specNames,
360
+ specRels,
289
361
  specExists,
290
362
  clauses,
291
363
  covered,