@nurix/etna 0.20.11 → 0.20.13

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.
@@ -60,7 +60,7 @@ Test packages install at `latest` — outside the approved-packages pin set. Fak
60
60
 
61
61
  - **Suite, not scripts.** Tests live in the canonical test tree — `tests/` mirroring `src/`, or the convention the repo already uses — named `<subject>.test.ts`; shared fixtures, factories, and helpers in `tests/helpers/` (or the suite's existing shared home).
62
62
  - **Wire the canonical command.** `"test"` in `package.json` runs the whole unit suite (`vitest run`); e2e under `"test:e2e"`. A repo where `pnpm test` / `npm test` doesn't run the suite is misconfigured — fix the wiring, **never** route around it.
63
- - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob.
63
+ - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob. The e2e config **owns its world** — it builds, seeds, and boots the app itself (a `webServer` block or equivalent), so a journey passes from a cold shell like any other test and "the world was not running" is never why one did not run.
64
64
  - **The real-renderer stratum is a third tree** — `tests/rendered/` under the unit runner's *browser mode*, its own config, kept to geometry regressions that shipped and the composition assertions a component library cannot make alone. It is not a second component suite; anything a simulated DOM can prove stays in `tests/component/`.
65
65
  - **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via `supertest` — over mocks; mock **only** true externals (network via `msw`, time via the runner's fake timers).
66
66
  - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone, in any order, **and from a cold shell** — never against a hand-started dev server (Workers: `@cloudflare/vitest-pool-workers`; Express: `supertest` against the app instance).
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "7d6e2acfe663",
2
+ "fingerprint": "0eb49820003e",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -35,6 +35,8 @@ Three peer modes, then a verification gate and a Completion step — enter where
35
35
 
36
36
  > **Two stops, and only two.** The work between them runs without asking. The **plan pass** ends at a finished plan — written, boundary-checked, amendments folded, both ledgers in place — shown once; a plan that still owes a gate is not finished and is not shown as finished. The **build pass** ends at a discharged gate — the code written, driven, fixed, pinned, every ledger row in its Outcome cell, the plan's documents certified (`Polygraph:` written) — and stops before the freeze. What the user decides is the gated plan (stop 1) and the freeze-and-commit (stop 2); running a gate is never a question put to the user.
37
37
 
38
+ > **A build graph overrides all of this, and most sessions never see one.** When `.claude/state/graph.json` exists, a walker decides which document is produced next and when the run is finished: a write reconciles it, an injected line names the next node, and the turn cannot end while the graph can still advance. Produce the file the line names. Never edit the graph or its state — both are refused — and never rewrite a node that has already landed. No such file ⇒ nothing in this paragraph is running.
39
+
38
40
  > **The lightweight lane:** a change touching one module, **at most ~10 files, none on a contract-heavy surface** (db schema, wire envelopes, auth, events, env/config — the `boundary` skill's list), and under about **three days'** work skips the modes above entirely — the ad-hoc regime of `.claude/rules/testing.md` applies: make the change, drive it, then pin it in the same unit of work if it alters promised behavior, cover it with a changelog row. The predicate is mechanical; when in doubt, the `boundary` diff scan is the tiebreak, not self-assessment.
39
41
 
40
42
  **On Plan Completion:**
@@ -71,7 +73,7 @@ Boundaries are **diagnosed, not documented** — the `boundary` skill derives th
71
73
 
72
74
  ### Test obligations — specify before implementing
73
75
 
74
- **Then, still in the plan pass, invoke the `testing` skill (plan-gate mode)** — after `boundary`'s amendments are folded, ordered second. It derives the tests the plan owes from its Proposed Changes and the owning LLD, and writes the **Test Obligations** ledger into the plan (one row per promised behavior: layer · target file · oracle · fixtures · an empty **Outcome** cell the verification gate fills). It writes no test files — the ledger is what the verification gate discharges, and because it lives in the plan file it survives dispatch to an executor that sees only the plan. On a boundary **BLOCK** it does not run — re-plan first. Layer rules and the e2e altitude gate live in the skill. **The Doc Obligations table is the author's, filled in the same pass** — a lookup against the ownership index, not a skill run; the hook blocks implementation edits while either section is missing.
76
+ **Then, still in the plan pass, invoke the `testing` skill (plan-gate mode)** — after `boundary`'s amendments are folded, ordered second. It derives the tests the plan owes from its Proposed Changes and the owning LLD, and writes the **Test Obligations** ledger into the plan (one row per promised behavior: layer · target file · oracle · fixtures · an empty **Outcome** cell the verification gate fills). It writes no test files — the ledger is what the verification gate discharges, and because it lives in the plan file it survives dispatch to an executor that sees only the plan. On a boundary **BLOCK** it does not run — re-plan first. Layer rules live in the skill — on a rendered surface every user-observable promise is owed as its surface's journey, and component tests are residual. **The Doc Obligations table is the author's, filled in the same pass** — a lookup against the ownership index, not a skill run; the hook blocks implementation edits while either section is missing.
75
77
 
76
78
  ### Verification — prove before completing
77
79
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.15.1",
4
+ "version": "3.16.1",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -25,5 +25,5 @@
25
25
  }
26
26
  ],
27
27
  "docsRoot": "docs",
28
- "digest": "b00642c1a47d"
28
+ "digest": "3a8ab65bd90f"
29
29
  }
@@ -0,0 +1,64 @@
1
+ // harness/code/hooks/build-graph.mjs — the build-graph walker's Claude Code adapter (PostToolUse).
2
+ //
3
+ // A file was written. Reconcile the declared paths against the disk, and if the graph can now
4
+ // advance, tell the model what to produce next. That is the whole job: the deciding lives in
5
+ // `lib/graph.mjs`, the reading and writing in `lib/graph-store.mjs`, and this file is the six
6
+ // lines that turn a hook payload into a call and a call into stdout.
7
+ //
8
+ // PostToolUse only. The Stop side of the walker lives in `compliance.mjs`, because the runtime
9
+ // runs every hook of an event in parallel — two Stop hooks that both block leave which reason
10
+ // the model sees up to a race, and the walker's `mode.walls` already needs compliance to read
11
+ // the graph.
12
+ //
13
+ // DORMANT BY ABSENCE: no `.claude/state/graph.json` ⇒ this prints nothing, writes nothing, and
14
+ // creates no state. Every harness install ships it; only a run that was handed a graph notices.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
19
+ import { afterWrite } from "./lib/graph-store.mjs";
20
+
21
+ function main() {
22
+ const raw = readStdin();
23
+ let payload = {};
24
+ try {
25
+ payload = raw ? JSON.parse(raw) : {};
26
+ } catch (e) {
27
+ payload = {};
28
+ }
29
+ if (payload.hook_event_name && payload.hook_event_name !== "PostToolUse") return;
30
+
31
+ const projectDir = resolveProjectDir(payload);
32
+ const step = afterWrite(projectDir);
33
+ if (!step) return; // dormant
34
+
35
+ // An invalid graph is never a silent no-op. The Stop wall blocks on the same reason, so a run
36
+ // handed a broken graph hears about it twice rather than generating in the dark.
37
+ const context = step.refusal
38
+ ? `BUILD GRAPH REFUSED — \`.claude/state/graph.json\` exists but ${step.refusal}. Nothing is being steered: fix the graph or remove it. The walker is dormant only when the file is ABSENT, never when it is wrong.`
39
+ : step.line;
40
+ if (!context) return;
41
+
42
+ process.stdout.write(
43
+ JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: context } })
44
+ );
45
+ }
46
+
47
+ try {
48
+ main();
49
+ } catch (e) {
50
+ // Fail open, and leave a trace: a walker that crashes must not take the session with it, but a
51
+ // walker that stopped steering silently is a run nobody knows went unguided.
52
+ try {
53
+ const dir = path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), ".claude", "state");
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ fs.appendFileSync(path.join(dir, "hook-failures.log"), `${new Date().toISOString()} build-graph.mjs ${e?.message || e}\n`);
56
+ } catch (e2) {
57
+ /* truly nowhere to write — fail open stays absolute */
58
+ }
59
+ } finally {
60
+ // NEVER process.exit() here. Node does not flush pending async stdout writes on exit, so an
61
+ // injection larger than the pipe buffer is severed mid-write and arrives as unparseable JSON —
62
+ // which reads downstream as "no steer" rather than as an error. (awareness.mjs, same note.)
63
+ process.exitCode = 0;
64
+ }
@@ -24,6 +24,38 @@ import { undischargedRows } from "./lib/ledger.mjs";
24
24
  import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
25
25
  import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
26
26
  import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
27
+ import { atStop, graphDenial, wallsAllowlist } from "./lib/graph-store.mjs";
28
+ import { WALLS, wallEnabled } from "./lib/walls.mjs";
29
+
30
+ // --- the build graph's switch ------------------------------------------------------------
31
+ // A run handed a build graph (docs/harness/code/build-graph.md) may declare `mode.walls`, an
32
+ // allowlist over lib/walls.mjs. Every wall it does not name is off for that session — because
33
+ // the compliance set assumes a session that can dispatch a bookkeeper and a user who can answer
34
+ // an ask, and a generation box has neither: its walls would be unresolvable blocks, not guard
35
+ // rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was.
36
+ let allowlistCache;
37
+ function wallOn(projectDir, name) {
38
+ if (allowlistCache === undefined) {
39
+ try {
40
+ allowlistCache = wallsAllowlist(projectDir);
41
+ } catch (e) {
42
+ allowlistCache = null; // a broken graph never switches a wall off
43
+ }
44
+ }
45
+ return wallEnabled(allowlistCache, name);
46
+ }
47
+
48
+ // The graph's own wall: a landed node is written once, and the graph and its state are machinery.
49
+ // It answers with decision("deny", …) and NEVER deny() — a steer is not a failure, and a logged
50
+ // denial in a generation box would need a `resolved` line nobody is there to write.
51
+ function graphGate(projectDir, rel) {
52
+ try {
53
+ const reason = graphDenial(projectDir, rel);
54
+ return reason ? decision("deny", reason) : null;
55
+ } catch (e) {
56
+ return null; // the walker never becomes an outage
57
+ }
58
+ }
27
59
 
28
60
  function decision(permissionDecision, reason) {
29
61
  return {
@@ -63,7 +95,7 @@ function protectedPaths(projectDir) {
63
95
  }
64
96
 
65
97
  function fileGate(projectDir, rel, sessionId) {
66
- const frozen = frozenStatusAtHead(projectDir, rel);
98
+ const frozen = wallOn(projectDir, "frozen-record") ? frozenStatusAtHead(projectDir, rel) : { frozen: false };
67
99
  if (frozen.frozen) {
68
100
  return deny(
69
101
  projectDir,
@@ -74,15 +106,22 @@ function fileGate(projectDir, rel, sessionId) {
74
106
  );
75
107
  }
76
108
 
77
- if (protectedPaths(projectDir).includes(rel)) {
109
+ if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
78
110
  return deny(projectDir, sessionId, "protected-file", rel, `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
79
111
  }
80
112
 
113
+ // Before the claim ask and outside the plan gate's `inDocs` skip — the graph's own files live
114
+ // under .claude/, which that branch deliberately excludes.
115
+ const graphWall = graphGate(projectDir, rel);
116
+ if (graphWall) return graphWall;
117
+
81
118
  // The file half of the claim board: a path another LIVE session touched minutes ago belongs to
82
119
  // someone else right now. Advisory by design — one ask per sibling per session, never a wall —
83
120
  // this is the presence protocol ("git status archaeology") moved into the gate that fires anyway.
84
121
  try {
85
- const holder = recentForeignTouches(projectDir, sessionId).find((f) => f.paths.some((p) => p.rel === rel));
122
+ const holder = wallOn(projectDir, "foreign-claim")
123
+ ? recentForeignTouches(projectDir, sessionId).find((f) => f.paths.some((p) => p.rel === rel))
124
+ : null;
86
125
  if (holder) {
87
126
  const recent = holder.paths.find((p) => p.rel === rel);
88
127
  appendEvent(projectDir, sessionId, "coord", "file-claim", rel, `touched ${recent.ageMin}m ago by sid=${holder.sid8}`);
@@ -99,7 +138,7 @@ function fileGate(projectDir, rel, sessionId) {
99
138
  }
100
139
 
101
140
  const inDocs = rel.startsWith(`${docsRoot(projectDir)}/`) || rel.startsWith(".claude/") || rel.endsWith(".md");
102
- if (!inDocs && SOURCE_EXT.test(rel)) {
141
+ if (!inDocs && SOURCE_EXT.test(rel) && wallOn(projectDir, "plan-gate")) {
103
142
  for (const plan of openGatedPlans(projectDir)) {
104
143
  // The gate binds the plan's own paths, so disjoint plans run in parallel.
105
144
  // A scopeless plan stays repo-wide — fail closed.
@@ -167,7 +206,7 @@ function commitGate(projectDir, sessionId) {
167
206
  .filter(Boolean);
168
207
  const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
169
208
  const root = docsRoot(projectDir);
170
- if (staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
209
+ if (wallOn(projectDir, "changelog") && staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
171
210
  if (!staged.some((f) => f.startsWith(`${root}/history/changelog/`))) {
172
211
  return deny(
173
212
  projectDir,
@@ -184,7 +223,9 @@ function commitGate(projectDir, sessionId) {
184
223
  // action that clears it. The read goes through `liveRows` first: a gate tests conditions at gate
185
224
  // time, never a cached record of them — a row whose condition died since the last sweep must not
186
225
  // wall anything. Pure by design; the detector's next sweep deletes what this filtered out.
187
- const { blocking, unrelated } = rowsBlockingCommit(liveRows(projectDir, readQueue(projectDir)), staged);
226
+ const { blocking, unrelated } = wallOn(projectDir, "bookkeeping")
227
+ ? rowsBlockingCommit(liveRows(projectDir, readQueue(projectDir)), staged)
228
+ : { blocking: [], unrelated: [] };
188
229
  if (blocking.length > 0) {
189
230
  const shown = blocking.slice(0, 8);
190
231
  const lines = shown.map(({ row, hits }) =>
@@ -221,6 +262,7 @@ function commitGate(projectDir, sessionId) {
221
262
  const PKG_SPEC = /^(@[a-z0-9~-][\w.-]*\/)?[a-z0-9~-][\w.-]*(@[\w.^~*<>=|x-]+)?$/;
222
263
 
223
264
  function packageGate(projectDir, command, sessionId) {
265
+ if (!wallOn(projectDir, "package-table")) return null;
224
266
  const m = new RegExp(
225
267
  String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|\n]*)`
226
268
  ).exec(command);
@@ -288,6 +330,7 @@ function bashGate(projectDir, command, sessionId) {
288
330
  if (!command) return null;
289
331
 
290
332
  if (statement(String.raw`git\s+commit\b`).test(command)) {
333
+ if (!wallOn(projectDir, "commit-claim")) return commitGate(projectDir, sessionId);
291
334
  // The claim board serializes the commit sequence: disjoint files do not make disjoint
292
335
  // commits — every session writes the same branch through the same index, so one committer
293
336
  // at a time. A held claim is a plain wait (no denied/resolved pair to resolve — the retry
@@ -313,7 +356,7 @@ function bashGate(projectDir, command, sessionId) {
313
356
  }
314
357
 
315
358
  // `git branch <name>` creates; flag-led forms (--show-current, --list, -d, -vv) query or manage
316
- if (statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`).test(command)) {
359
+ if (wallOn(projectDir, "branch-worktree") && statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`).test(command)) {
317
360
  return askOnce(
318
361
  projectDir,
319
362
  sessionId,
@@ -323,6 +366,7 @@ function bashGate(projectDir, command, sessionId) {
323
366
  }
324
367
 
325
368
  if (
369
+ wallOn(projectDir, "live-command") &&
326
370
  statement(String.raw`wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`).test(command) &&
327
371
  !/--dry-run\b|--local\b/.test(command)
328
372
  ) {
@@ -366,7 +410,11 @@ function bashWriteScreen(projectDir, command, sessionId) {
366
410
  for (const target of bashWriteTargets(command)) {
367
411
  const rel = relIfInside(projectDir, target);
368
412
  if (!rel) continue;
369
- const frozen = frozenStatusAtHead(projectDir, rel);
413
+ // A shell write is still a write: the build graph's wall runs here too, or `cat > docs/prd.md`
414
+ // over a landed node ships green while the Edit tool is refused.
415
+ const graphWall = graphGate(projectDir, rel);
416
+ if (graphWall) return graphWall;
417
+ const frozen = wallOn(projectDir, "frozen-record") ? frozenStatusAtHead(projectDir, rel) : { frozen: false };
370
418
  if (frozen.frozen) {
371
419
  return deny(
372
420
  projectDir,
@@ -376,7 +424,7 @@ function bashWriteScreen(projectDir, command, sessionId) {
376
424
  `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}) — a shell write is still a write. A correction is a new dated entry that links back.`
377
425
  );
378
426
  }
379
- if (protectedPaths(projectDir).includes(rel)) {
427
+ if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
380
428
  return deny(
381
429
  projectDir,
382
430
  sessionId,
@@ -443,7 +491,54 @@ function planHasChangelogEntry(projectDir, planRel) {
443
491
  return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
444
492
  }
445
493
 
494
+ // The Stop half of the build-graph walker. Blocks with the next injection line while the graph
495
+ // can still advance, allows the moment it is terminal, and answers null when no graph exists —
496
+ // which is every session in every repo that was never handed one.
497
+ function graphStop(projectDir, stopHookActive) {
498
+ let step;
499
+ try {
500
+ step = atStop(projectDir);
501
+ } catch (e) {
502
+ return null; // the walker never becomes an outage
503
+ }
504
+ if (!step) return null;
505
+ if (step.refusal) {
506
+ // A refusal the model cannot repair (the wall denies writes to the graph) must not become an
507
+ // unbreakable Stop loop: say it once, loudly, and let the second Stop through. Fixing the
508
+ // graph is the run owner's job, not the model's — so this branch, alone in this function,
509
+ // honours the runtime's loop guard.
510
+ if (stopHookActive) return null;
511
+ return {
512
+ decision: "block",
513
+ reason:
514
+ `BUILD GRAPH REFUSED — \`.claude/state/graph.json\` exists but ${step.refusal}. Nothing is being steered. ` +
515
+ "Fix the graph or remove it: the walker is dormant only when that file is ABSENT, never when it is wrong. (Said once — the next Stop passes.)",
516
+ };
517
+ }
518
+ if (!step.line) return null;
519
+ return {
520
+ decision: "block",
521
+ reason:
522
+ `${step.line} The build graph decides this run's order and completion — ${step.terminal.summary} ` +
523
+ "Write the file the line names; the run ends by itself when the graph is complete, blocked, or out of attempts.",
524
+ };
525
+ }
526
+
446
527
  function stopGate(projectDir, payload) {
528
+ // The build graph is asked BEFORE the runtime's loop guard, and deliberately ignores it. The
529
+ // guard exists because an unbounded Stop hook is a hang; the walker is bounded instead — every
530
+ // block charges an attempt on the node it names, so a model that answers "done" spends that
531
+ // node's budget and the run ends `exhausted`. Honouring the flag would leave the walker able to
532
+ // steer exactly once per turn chain and then go quiet with documents still owed, which is the
533
+ // liveness hole the whole design exists to close.
534
+ //
535
+ // Consequence, and it is deliberate: while a graph is live the first block flips
536
+ // `stop_hook_active` for the rest of the chain, so the four sub-walls below are suspended until
537
+ // the graph is terminal. A generation box wants exactly that; a human session running a
538
+ // hand-written graph should know it.
539
+ const graph = graphStop(projectDir, payload.stop_hook_active);
540
+ if (graph) return graph;
541
+
447
542
  if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
448
543
 
449
544
  // The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
@@ -456,7 +551,7 @@ function stopGate(projectDir, payload) {
456
551
  readQueue(projectDir).filter((row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`))
457
552
  );
458
553
  const counts = countRows(mine);
459
- if (counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
554
+ if (wallOn(projectDir, "queue") && counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
460
555
  return {
461
556
  decision: "block",
462
557
  reason:
@@ -472,7 +567,7 @@ function stopGate(projectDir, payload) {
472
567
  // Open gate denials wall the stop: silence after a block is the one ending the log exists to
473
568
  // make impossible. Resolution appends to the log — retried-ok is machinery-written on a
474
569
  // successful retry; waived/disclosed are the session's hand-written, auditable claims.
475
- const openDenials = unresolvedDenials(projectDir, payload.session_id);
570
+ const openDenials = wallOn(projectDir, "open-denials") ? unresolvedDenials(projectDir, payload.session_id) : [];
476
571
  if (openDenials.length > 0) {
477
572
  const rel = path.relative(projectDir, sessionLogPath(projectDir, payload.session_id));
478
573
  // A resolution that doesn't parse reads to the session as "my resolution didn't count"
@@ -502,7 +597,7 @@ function stopGate(projectDir, payload) {
502
597
  // The Test Obligations ledger IS walled per row: the Outcome cell lives in the plan file, so the
503
598
  // only way past this block is reading the file — a context-held copy of the ledger is exactly
504
599
  // how rows get missed. (Tables without an Outcome column are legacy and pass; see lib/ledger.)
505
- for (const rel of completed) {
600
+ for (const rel of wallOn(projectDir, "ledger") ? completed : []) {
506
601
  const rows = undischargedRows(readSafe(path.join(projectDir, rel)));
507
602
  if (rows.length > 0) {
508
603
  return {
@@ -514,7 +609,9 @@ function stopGate(projectDir, payload) {
514
609
  }
515
610
  }
516
611
 
517
- const undocumented = completed.filter((rel) => !planHasChangelogEntry(projectDir, rel));
612
+ const undocumented = wallOn(projectDir, "changelog-entry")
613
+ ? completed.filter((rel) => !planHasChangelogEntry(projectDir, rel))
614
+ : [];
518
615
  if (undocumented.length > 0) {
519
616
  return {
520
617
  decision: "block",
@@ -11,7 +11,7 @@
11
11
  { "matcher": "", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 } ] }
12
12
  ],
13
13
  "PostToolUse": [
14
- { "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 } ] }
14
+ { "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/build-graph.mjs\"", "timeout": 10 } ] }
15
15
  ],
16
16
  "PreToolUse": [
17
17
  { "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"", "timeout": 10 } ] }
@@ -0,0 +1,183 @@
1
+ // harness/code/hooks/lib/graph-store.mjs — the build-graph walker: the filesystem half.
2
+ //
3
+ // `lib/graph.mjs` decides; this module reads and writes. It exists because the Claude Code side
4
+ // of the walker has TWO entry points — the PostToolUse adapter (`build-graph.mjs`) and the Stop
5
+ // wall inside `compliance.mjs` — and a walker whose two triggers disagreed about how state is
6
+ // loaded would be worse than no walker at all.
7
+ //
8
+ // Everything here fails toward dormancy: no graph file, an unreadable one, a crash mid-scan —
9
+ // none of them may block a session that never asked for a build graph.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { graphFile, graphStateFile, atomicWrite, GRAPH_FILE, GRAPH_STATE_FILE } from "./state.mjs";
14
+ import { readGraph, reconcile, rebuildState, initialState, nextEligible, inProgress, select, isTerminal, normalizeGraphPath, STATE_VERSION } from "./graph.mjs";
15
+
16
+ // The two paths the model may never write, spelled once.
17
+ export const GRAPH_PATHS = [`.claude/state/${GRAPH_FILE}`, `.claude/state/${GRAPH_STATE_FILE}`];
18
+
19
+ // Absent → { present: false }: dormancy, and the caller does nothing at all.
20
+ // Present but unparseable → { present: true, refusal }: loud, never silent.
21
+ export function loadGraph(projectDir) {
22
+ let text;
23
+ try {
24
+ text = fs.readFileSync(graphFile(projectDir), "utf8");
25
+ } catch (e) {
26
+ return { present: false };
27
+ }
28
+ const parsed = readGraph(text, projectDir);
29
+ if (!parsed.ok) return { present: true, refusal: parsed.reason };
30
+ return { present: true, graph: parsed.graph };
31
+ }
32
+
33
+ // Read every DECLARED path off the disk. Never the tool's argument: `bash` writes files too, a
34
+ // single command writes several, and an argument naming a path proves nothing about what landed.
35
+ export function observe(projectDir, graph) {
36
+ return graph.nodes.map((node) => {
37
+ const full = path.join(projectDir, node.path);
38
+ try {
39
+ const text = fs.readFileSync(full, "utf8");
40
+ return { path: node.path, exists: true, bytes: Buffer.byteLength(text, "utf8"), text };
41
+ } catch (e) {
42
+ return { path: node.path, exists: false };
43
+ }
44
+ });
45
+ }
46
+
47
+ // An unreadable or version-mismatched state file is REBUILT from the disk, never refused: the
48
+ // graph plus the files that exist are the whole truth, and the state file is only a cache of
49
+ // counters. Refusing here would strand a run over a byte nobody can repair.
50
+ export function loadState(projectDir, graph, observed) {
51
+ let parsed = null;
52
+ try {
53
+ parsed = JSON.parse(fs.readFileSync(graphStateFile(projectDir), "utf8"));
54
+ } catch (e) {
55
+ parsed = null;
56
+ }
57
+ if (!parsed || typeof parsed !== "object" || parsed.v !== STATE_VERSION || !parsed.nodes || typeof parsed.nodes !== "object") {
58
+ return { state: rebuildState(graph, observed, parsed), rebuilt: true };
59
+ }
60
+ return { state: parsed, rebuilt: false };
61
+ }
62
+
63
+ export function saveState(projectDir, state) {
64
+ atomicWrite(graphStateFile(projectDir), `${JSON.stringify(state, null, 2)}\n`);
65
+ }
66
+
67
+ // --- the two triggers ---------------------------------------------------------------------
68
+
69
+ // PostToolUse: a write happened. Reconcile the disk, then steer only when there is somewhere new
70
+ // to steer — a node still in progress gets silence, which is what keeps a six-edit document from
71
+ // spending six attempts.
72
+ //
73
+ // `writes` counts EVENTS, not files: one shell command that writes three files is one write,
74
+ // because the cap exists to bound the session, not to audit the filesystem.
75
+ export function afterWrite(projectDir) {
76
+ const loaded = loadGraph(projectDir);
77
+ if (!loaded.present) return null;
78
+ if (loaded.refusal) return { refusal: loaded.refusal };
79
+ const graph = loaded.graph;
80
+
81
+ const observed = observe(projectDir, graph);
82
+ const before = loadState(projectDir, graph, observed);
83
+ const running = inProgress(graph, before.state);
84
+ let state = reconcile(graph, before.state, observed);
85
+ state.writes = (state.writes || 0) + 1;
86
+
87
+ const terminal = isTerminal(graph, state);
88
+ if (terminal.terminal) {
89
+ saveState(projectDir, state);
90
+ return { graph, state, terminal, line: terminal.summary };
91
+ }
92
+
93
+ // Still working on the node it was told to produce — say nothing.
94
+ const stillRunning = inProgress(graph, state);
95
+ if (stillRunning && running && stillRunning.id === running.id) {
96
+ saveState(projectDir, state);
97
+ return { graph, state, terminal, line: null };
98
+ }
99
+
100
+ const next = nextEligible(graph, state);
101
+ if (!next) {
102
+ saveState(projectDir, state);
103
+ return { graph, state, terminal, line: null };
104
+ }
105
+ const picked = select(graph, state, next.id);
106
+ saveState(projectDir, picked.state);
107
+ return { graph, state: picked.state, terminal, line: picked.line };
108
+ }
109
+
110
+ // Stop: the model is trying to end its turn. While the graph can still advance, it does not get
111
+ // to — and the block CHARGES an attempt on the node it names, which is the whole reason the loop
112
+ // terminates: a model that answers "done" three times spends the node's budget and the run ends
113
+ // `exhausted` instead of circling forever.
114
+ //
115
+ // This deliberately ignores the runtime's `stop_hook_active` loop guard (the caller checks the
116
+ // graph before consulting it). The guard exists because an unbounded Stop hook is a hang; this
117
+ // one is bounded by maxAttempts × nodes and by maxWrites, so the guard would only cost the
118
+ // walker its liveness — it would steer exactly once per turn chain and then go quiet with nodes
119
+ // still owed.
120
+ export function atStop(projectDir) {
121
+ const loaded = loadGraph(projectDir);
122
+ if (!loaded.present) return null;
123
+ if (loaded.refusal) return { refusal: loaded.refusal };
124
+ const graph = loaded.graph;
125
+
126
+ const observed = observe(projectDir, graph);
127
+ const before = loadState(projectDir, graph, observed);
128
+ let state = reconcile(graph, before.state, observed);
129
+
130
+ const terminal = isTerminal(graph, state);
131
+ if (terminal.terminal) {
132
+ saveState(projectDir, state);
133
+ return { graph, state, terminal, line: null };
134
+ }
135
+
136
+ const target = inProgress(graph, state) || nextEligible(graph, state);
137
+ if (!target) {
138
+ saveState(projectDir, state);
139
+ return { graph, state, terminal, line: null };
140
+ }
141
+ const picked = select(graph, state, target.id);
142
+ saveState(projectDir, picked.state);
143
+ return { graph, state: picked.state, terminal: isTerminal(graph, picked.state), line: picked.line };
144
+ }
145
+
146
+ // --- the walls the graph owns ---------------------------------------------------------------
147
+
148
+ // A done node is frozen, and the graph and its state are machinery. Returns a deny REASON (the
149
+ // caller wraps it in its own runtime's envelope) or null.
150
+ //
151
+ // Note what this never does: append a `denied` event to the session gate log. A steer is not a
152
+ // failure, and in a generation box nobody is there to write the `resolved` line that a logged
153
+ // denial would need — the Stop wall would block forever on the walker's own refusals.
154
+ export function graphDenial(projectDir, rel) {
155
+ const target = normalizeGraphPath(rel, projectDir);
156
+ if (!target) return null;
157
+ if (GRAPH_PATHS.includes(target)) {
158
+ return `BLOCKED — build-graph machinery. \`${target}\` is the run's graph or its state; the walker owns both. Write the document the injected line names instead.`;
159
+ }
160
+ const loaded = loadGraph(projectDir);
161
+ if (!loaded.present || loaded.refusal) return null;
162
+ const graph = loaded.graph;
163
+ const node = graph.nodes.find((n) => n.path === target);
164
+ if (!node) return null;
165
+ const observed = observe(projectDir, graph);
166
+ const { state } = loadState(projectDir, graph, observed);
167
+ const settled = reconcile(graph, state, observed);
168
+ if (settled.nodes[node.id]?.status !== "done") return null;
169
+ return `BLOCKED — frozen by the build graph. \`${target}\` is node \`${node.id}\`, which has already landed; the graph's outputs are written once. If it is genuinely wrong, the run's owner edits the graph, not the model.`;
170
+ }
171
+
172
+ // `mode.walls` is an allowlist over the gate ids in lib/walls.mjs: when a graph declares one,
173
+ // every wall it does not name is off for the session. No `mode` block at all ⇒ every wall on,
174
+ // which is what every repo without a graph already has.
175
+ //
176
+ // The reason a generation box wants this: the compliance set assumes a session that can dispatch
177
+ // a bookkeeper and a user who can answer an ask. A box has neither, so its walls would be
178
+ // unresolvable blocks rather than guard rails.
179
+ export function wallsAllowlist(projectDir) {
180
+ const loaded = loadGraph(projectDir);
181
+ if (!loaded.present || loaded.refusal || !loaded.graph.mode) return null;
182
+ return loaded.graph.mode.walls;
183
+ }
@@ -0,0 +1,360 @@
1
+ // harness/code/hooks/lib/graph.mjs — the build-graph walker: the pure half.
2
+ //
3
+ // A build graph names the documents a run owes, the edges between them, and the caps that bound
4
+ // it. This module decides WHAT happens next; it never touches the filesystem, the transcript, or
5
+ // the clock beyond an injected timestamp — every adapter (Claude Code's hooks here, OpenCode's
6
+ // plugin in Apollo's generation box) hands it what it read and applies what it returns.
7
+ //
8
+ // The division is the point: one library decides, several runtimes carry the decision, and the
9
+ // two can never disagree about what "done" means. The export set below is a cross-repo contract
10
+ // (Apollo imports this file from the installed `.claude/hooks/lib/graph.mjs`) — a rename is a
11
+ // versioned change, not a refactor, and `tests/parity.test.mjs` pins it.
12
+ //
13
+ // Absence is dormancy; invalidity is a loud refusal. No graph file ⇒ nothing here ever runs. A
14
+ // graph that exists but does not parse is never silently ignored — `readGraph` answers a typed
15
+ // refusal the adapter surfaces.
16
+
17
+ import path from "node:path";
18
+
19
+ export const GRAPH_VERSION = 1;
20
+ export const STATE_VERSION = 1;
21
+ export const DEFAULT_MAX_ATTEMPTS = 3;
22
+ export const DEFAULT_MAX_WRITES = 200;
23
+
24
+ // pending → in_progress → done | failed. `failed` is derived, never authored: a node that is not
25
+ // done once it has spent its attempts can no longer advance, and saying so is what lets the run
26
+ // end instead of circling.
27
+ export const STATUSES = ["pending", "in_progress", "done", "failed"];
28
+
29
+ // --- paths ------------------------------------------------------------------------------------
30
+
31
+ // Graph paths, tool arguments and disk scans must compare as one spelling: `./docs/prd.md`,
32
+ // `docs//prd.md` and an absolute path inside the project all name the same node. Pure string math
33
+ // plus path.relative — no filesystem.
34
+ export function normalizeGraphPath(p, projectDir) {
35
+ if (typeof p !== "string") return null;
36
+ let s = p.trim().split("\\").join("/");
37
+ if (!s) return null;
38
+ if (projectDir && path.isAbsolute(s)) {
39
+ const rel = path.relative(projectDir, path.resolve(s));
40
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
41
+ s = rel.split(path.sep).join("/");
42
+ }
43
+ if (path.isAbsolute(s)) return null;
44
+ s = path.posix.normalize(s);
45
+ while (s.startsWith("./")) s = s.slice(2);
46
+ while (s.endsWith("/")) s = s.slice(0, -1);
47
+ if (!s || s === "." || s.startsWith("../")) return null;
48
+ return s;
49
+ }
50
+
51
+ // --- reading the graph ------------------------------------------------------------------------
52
+
53
+ const refuse = (reason) => ({ ok: false, reason });
54
+
55
+ function positiveInt(value, fallback) {
56
+ if (value === undefined || value === null) return fallback;
57
+ return Number.isInteger(value) && value > 0 ? value : null;
58
+ }
59
+
60
+ // Unknown keys are ignored by design, so a v:1 writer can add a field (a `docType` check, say)
61
+ // without every installed harness refusing the graph it has always been able to walk.
62
+ function readValidate(value, id) {
63
+ if (value === undefined || value === null) return { value: null };
64
+ if (typeof value !== "object" || Array.isArray(value)) return { error: "`validate` is not an object" };
65
+ const out = {};
66
+ if (value.minBytes !== undefined && value.minBytes !== null) {
67
+ if (!Number.isInteger(value.minBytes) || value.minBytes < 0) return { error: "`validate.minBytes` is not a non-negative integer" };
68
+ out.minBytes = value.minBytes;
69
+ }
70
+ if (value.headings !== undefined && value.headings !== null) {
71
+ if (!Array.isArray(value.headings) || value.headings.some((h) => typeof h !== "string" || !h.trim())) {
72
+ return { error: "`validate.headings` is not a list of non-empty strings" };
73
+ }
74
+ out.headings = value.headings.map((h) => h.trim());
75
+ }
76
+ return { value: Object.keys(out).length ? out : null };
77
+ }
78
+
79
+ // Depth-first colouring; returns the cycle as a readable chain, or null.
80
+ function findCycle(nodes, byId) {
81
+ const state = new Map();
82
+ const stack = [];
83
+ let found = null;
84
+ const visit = (id) => {
85
+ if (found) return;
86
+ const colour = state.get(id);
87
+ if (colour === "done") return;
88
+ if (colour === "open") {
89
+ found = [...stack.slice(stack.indexOf(id)), id];
90
+ return;
91
+ }
92
+ state.set(id, "open");
93
+ stack.push(id);
94
+ for (const dep of byId.get(id).deps) visit(dep);
95
+ stack.pop();
96
+ state.set(id, "done");
97
+ };
98
+ for (const node of nodes) visit(node.id);
99
+ return found;
100
+ }
101
+
102
+ // text → { ok: true, graph } | { ok: false, reason }. Never throws: an adapter calls this on
103
+ // whatever bytes were on disk.
104
+ export function readGraph(text, projectDir) {
105
+ let raw;
106
+ try {
107
+ raw = JSON.parse(String(text ?? ""));
108
+ } catch (e) {
109
+ return refuse(`it is not valid JSON (${e?.message || e})`);
110
+ }
111
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return refuse("it is not a JSON object");
112
+ if (raw.v !== GRAPH_VERSION) {
113
+ return refuse(`its version is ${JSON.stringify(raw.v)}, and this harness reads v${GRAPH_VERSION} graphs only`);
114
+ }
115
+ if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) return refuse("`nodes` is missing or empty");
116
+
117
+ const nodes = [];
118
+ const byId = new Map();
119
+ const byPath = new Map();
120
+ for (const [i, entry] of raw.nodes.entries()) {
121
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return refuse(`node #${i + 1} is not an object`);
122
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
123
+ if (!id) return refuse(`node #${i + 1} has no \`id\``);
124
+ if (byId.has(id)) return refuse(`two nodes share the id \`${id}\``);
125
+ const nodePath = normalizeGraphPath(entry.path, projectDir);
126
+ if (!nodePath) return refuse(`node \`${id}\` has no usable \`path\` (it must be relative and inside the project)`);
127
+ if (byPath.has(nodePath)) return refuse(`nodes \`${byPath.get(nodePath)}\` and \`${id}\` both claim \`${nodePath}\``);
128
+ const rawDeps = entry.deps === undefined || entry.deps === null ? [] : entry.deps;
129
+ if (!Array.isArray(rawDeps) || rawDeps.some((d) => typeof d !== "string" || !d.trim())) {
130
+ return refuse(`node \`${id}\` has a \`deps\` entry that is not a node id`);
131
+ }
132
+ const validate = readValidate(entry.validate, id);
133
+ if (validate.error) return refuse(`node \`${id}\`: ${validate.error}`);
134
+ const node = { id, path: nodePath, deps: rawDeps.map((d) => d.trim()), validate: validate.value };
135
+ byId.set(id, node);
136
+ byPath.set(nodePath, id);
137
+ nodes.push(node);
138
+ }
139
+
140
+ for (const node of nodes) {
141
+ for (const dep of node.deps) {
142
+ if (dep === node.id) return refuse(`node \`${node.id}\` depends on itself`);
143
+ if (!byId.has(dep)) return refuse(`node \`${node.id}\` depends on \`${dep}\`, which no node declares`);
144
+ }
145
+ }
146
+ const cycle = findCycle(nodes, byId);
147
+ if (cycle) return refuse(`the nodes form a cycle: ${cycle.join(" → ")}`);
148
+
149
+ const maxAttempts = positiveInt(raw.maxAttempts, DEFAULT_MAX_ATTEMPTS);
150
+ if (maxAttempts === null) return refuse("`maxAttempts` is not a positive integer");
151
+ const maxWrites = positiveInt(raw.maxWrites, DEFAULT_MAX_WRITES);
152
+ if (maxWrites === null) return refuse("`maxWrites` is not a positive integer");
153
+
154
+ let mode = null;
155
+ if (raw.mode !== undefined && raw.mode !== null) {
156
+ if (typeof raw.mode !== "object" || Array.isArray(raw.mode)) return refuse("`mode` is not an object");
157
+ const walls = raw.mode.walls;
158
+ if (walls !== undefined && walls !== null) {
159
+ if (!Array.isArray(walls) || walls.some((w) => typeof w !== "string")) return refuse("`mode.walls` is not a list of wall names");
160
+ mode = { walls: walls.map((w) => w.trim()).filter(Boolean) };
161
+ } else {
162
+ mode = { walls: null };
163
+ }
164
+ }
165
+
166
+ return { ok: true, graph: { v: GRAPH_VERSION, nodes, maxAttempts, maxWrites, mode } };
167
+ }
168
+
169
+ // --- state ------------------------------------------------------------------------------------
170
+
171
+ export function initialState(graph, runId) {
172
+ const nodes = {};
173
+ for (const node of graph.nodes) nodes[node.id] = { status: "pending", attempts: 0 };
174
+ return { v: STATE_VERSION, runId: runId ?? null, writes: 0, nodes };
175
+ }
176
+
177
+ // Every entry point starts here: a state carried across a graph edit (a node added, one renamed)
178
+ // is completed rather than refused, and a foreign key is dropped rather than walked.
179
+ function cloneState(graph, state) {
180
+ const next = { v: STATE_VERSION, runId: state?.runId ?? null, writes: Number.isInteger(state?.writes) ? state.writes : 0, nodes: {} };
181
+ for (const node of graph.nodes) {
182
+ const cell = state?.nodes?.[node.id];
183
+ const status = STATUSES.includes(cell?.status) ? cell.status : "pending";
184
+ next.nodes[node.id] = { status, attempts: Number.isInteger(cell?.attempts) ? cell.attempts : 0 };
185
+ if (cell?.writtenAt) next.nodes[node.id].writtenAt = cell.writtenAt;
186
+ if (cell?.lastError) next.nodes[node.id].lastError = cell.lastError;
187
+ }
188
+ return next;
189
+ }
190
+
191
+ // The structural gate: exists, non-empty, big enough, carries the headings the graph asked for.
192
+ // Semantic quality is never this module's business — it decides whether a node LANDED, not
193
+ // whether what landed is any good.
194
+ function validationFailure(node, observation) {
195
+ const text = typeof observation.text === "string" ? observation.text : null;
196
+ const bytes = Number.isInteger(observation.bytes) ? observation.bytes : text === null ? null : Buffer.byteLength(text, "utf8");
197
+ if (bytes === null) return null;
198
+ if (bytes === 0) return "the file is empty";
199
+ const minBytes = node.validate?.minBytes;
200
+ if (Number.isInteger(minBytes) && bytes < minBytes) return `the file is ${bytes} bytes, under the ${minBytes} the graph requires`;
201
+ const headings = node.validate?.headings;
202
+ if (headings?.length) {
203
+ if (text === null) return "the file could not be read to check its headings";
204
+ const missing = headings.filter((h) => !text.includes(h));
205
+ if (missing.length) return `it is missing ${missing.map((h) => `"${h}"`).join(", ")}`;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ // `observed` is what the adapter found on disk for the DECLARED paths — never a tool argument,
211
+ // because a shell command writes files too and names none of them in a way a hook can trust.
212
+ // Absent from the list (or `exists: false`) means the file is not there: the node is untouched.
213
+ export function reconcile(graph, state, observed, now = new Date().toISOString()) {
214
+ const next = cloneState(graph, state);
215
+ const seen = new Map();
216
+ for (const entry of observed || []) {
217
+ if (!entry || entry.exists === false) continue;
218
+ const p = normalizeGraphPath(entry.path);
219
+ if (p) seen.set(p, entry);
220
+ }
221
+ for (const node of graph.nodes) {
222
+ const cell = next.nodes[node.id];
223
+ if (cell.status === "done") continue; // a done node is frozen — its file is never re-judged
224
+ const observation = seen.get(node.path);
225
+ if (!observation) continue;
226
+ const failure = validationFailure(node, observation);
227
+ if (failure) {
228
+ if (cell.status === "in_progress") cell.status = "pending";
229
+ cell.lastError = failure;
230
+ continue;
231
+ }
232
+ cell.status = "done";
233
+ delete cell.lastError;
234
+ if (!cell.writtenAt) cell.writtenAt = now;
235
+ }
236
+ // Derived, and derived last: a node that is not done and has spent its attempts can never
237
+ // advance again. Saying so here is what turns "the model keeps not writing it" into a
238
+ // terminal run instead of an unbounded loop.
239
+ for (const node of graph.nodes) {
240
+ const cell = next.nodes[node.id];
241
+ if (cell.status !== "done" && cell.attempts >= graph.maxAttempts) cell.status = "failed";
242
+ }
243
+ return next;
244
+ }
245
+
246
+ // The corrupt-state path, and Apollo's resume path: derive a state from the disk. `prev` carries
247
+ // only the attempt counters — a resumed run must not get its budget back.
248
+ export function rebuildState(graph, observed, prev = null) {
249
+ const base = initialState(graph, prev?.runId ?? null);
250
+ if (prev?.nodes) {
251
+ for (const node of graph.nodes) {
252
+ const attempts = prev.nodes[node.id]?.attempts;
253
+ if (Number.isInteger(attempts) && attempts > 0) base.nodes[node.id].attempts = attempts;
254
+ }
255
+ }
256
+ return reconcile(graph, base, observed);
257
+ }
258
+
259
+ // --- selection --------------------------------------------------------------------------------
260
+
261
+ // Declaration order is the tie-break, so a graph reads top to bottom and two runs of the same
262
+ // graph pick the same node.
263
+ export function nextEligible(graph, state) {
264
+ const s = cloneState(graph, state);
265
+ for (const node of graph.nodes) {
266
+ const cell = s.nodes[node.id];
267
+ if (cell.status !== "pending") continue;
268
+ if (cell.attempts >= graph.maxAttempts) continue;
269
+ if (node.deps.some((dep) => s.nodes[dep]?.status !== "done")) continue;
270
+ return node;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // The node the model was last told to produce and has not finished.
276
+ export function inProgress(graph, state) {
277
+ const s = cloneState(graph, state);
278
+ return graph.nodes.find((node) => s.nodes[node.id].status === "in_progress") || null;
279
+ }
280
+
281
+ export function injectionLine(node) {
282
+ return `Now generate ${node.id} at ${node.path}.`;
283
+ }
284
+
285
+ // One selection, one attempt — never one write, one attempt. A document built by six edits spends
286
+ // one attempt; a turn that ends without landing the document spends the next one. That is what
287
+ // makes "the model keeps almost doing it" a bounded condition.
288
+ export function select(graph, state, id) {
289
+ const next = cloneState(graph, state);
290
+ const node = graph.nodes.find((n) => n.id === id) || null;
291
+ if (!node) return { state: next, node: null, line: null };
292
+ const cell = next.nodes[id];
293
+ cell.status = "in_progress";
294
+ cell.attempts = cell.attempts + 1;
295
+ return { state: next, node, line: injectionLine(node) };
296
+ }
297
+
298
+ // --- termination ------------------------------------------------------------------------------
299
+
300
+ // Nodes that can never run because something they depend on failed — transitively.
301
+ function unreachable(graph, state) {
302
+ const failed = new Set(graph.nodes.filter((n) => state.nodes[n.id].status === "failed").map((n) => n.id));
303
+ const blocked = new Set();
304
+ let moved = true;
305
+ while (moved) {
306
+ moved = false;
307
+ for (const node of graph.nodes) {
308
+ if (failed.has(node.id) || blocked.has(node.id)) continue;
309
+ if (state.nodes[node.id].status === "done") continue;
310
+ if (node.deps.some((dep) => failed.has(dep) || blocked.has(dep))) {
311
+ blocked.add(node.id);
312
+ moved = true;
313
+ }
314
+ }
315
+ }
316
+ return { failed: [...failed], blocked: [...blocked] };
317
+ }
318
+
319
+ // The run ends when nothing can advance — and the outcome says WHY, because "the box stopped" and
320
+ // "the box finished" must never look alike to the coordinator reading the seal.
321
+ export function isTerminal(graph, state) {
322
+ const s = cloneState(graph, state);
323
+ const remaining = graph.nodes.filter((n) => s.nodes[n.id].status !== "done");
324
+ if (remaining.length === 0) {
325
+ return { terminal: true, outcome: "complete", summary: `The build graph is complete — all ${graph.nodes.length} node(s) landed.` };
326
+ }
327
+
328
+ const overWrites = s.writes > graph.maxWrites;
329
+ if (!overWrites) {
330
+ const running = inProgress(graph, s);
331
+ if (running) {
332
+ return { terminal: false, outcome: null, summary: `In progress: ${running.id} at ${running.path}.` };
333
+ }
334
+ const next = nextEligible(graph, s);
335
+ if (next) {
336
+ return { terminal: false, outcome: null, summary: `Next: ${next.id} at ${next.path}.` };
337
+ }
338
+ }
339
+
340
+ const { failed, blocked } = unreachable(graph, s);
341
+ if (overWrites) {
342
+ return {
343
+ terminal: true,
344
+ outcome: "exhausted",
345
+ summary: `The build graph is exhausted — ${s.writes} writes passed the cap of ${graph.maxWrites} with ${remaining.map((n) => n.id).join(", ")} still owed.`,
346
+ };
347
+ }
348
+ if (blocked.length) {
349
+ return {
350
+ terminal: true,
351
+ outcome: "blocked",
352
+ summary: `The build graph is blocked — ${blocked.join(", ")} can never run because ${failed.join(", ")} failed.`,
353
+ };
354
+ }
355
+ return {
356
+ terminal: true,
357
+ outcome: "exhausted",
358
+ summary: `The build graph is exhausted — ${failed.join(", ")} spent ${graph.maxAttempts} attempt(s) without landing.`,
359
+ };
360
+ }
@@ -335,6 +335,23 @@ export function stateDir(projectDir) {
335
335
  return dir;
336
336
  }
337
337
 
338
+ // The build graph's two machine-local files. Named here so every reader — the walker's adapters,
339
+ // the compliance gates, a composer writing a graph before any session has run — spells them once.
340
+ export const GRAPH_FILE = "graph.json";
341
+ export const GRAPH_STATE_FILE = "build-graph-state.json";
342
+
343
+ // The graph is READ from a plain join: a composer may drop it in before any hook has run, and
344
+ // asking for it must never create the folder (absence is the walker's dormancy signal).
345
+ export function graphFile(projectDir) {
346
+ return path.join(projectDir, ".claude", "state", GRAPH_FILE);
347
+ }
348
+
349
+ // The state file is WRITTEN, so its accessor goes through stateDir() — which lays down the
350
+ // folder's `*` .gitignore on first use.
351
+ export function graphStateFile(projectDir) {
352
+ return path.join(stateDir(projectDir), GRAPH_STATE_FILE);
353
+ }
354
+
338
355
  // Session-scoped ask memory: an `ask` gate fires once per (session, class); after the user has
339
356
  // answered it once, repeats in the same session pass silently (the runtime's own permission
340
357
  // prompts still apply). Records live in .claude/state/asked.tsv — machine-local, self-ignored.
@@ -0,0 +1,34 @@
1
+ // harness/code/hooks/lib/walls.mjs — the wall vocabulary.
2
+ //
3
+ // One name per gate the compliance module can refuse or ask at, in one place, because a build
4
+ // graph's `mode.walls` is an allowlist over exactly these strings. A name that drifts from the
5
+ // id the code passes to `deny()` silently switches a wall off — `tests/parity.test.mjs` asserts
6
+ // the two sets agree, so a rename fails there rather than in a generation box at 3am.
7
+ //
8
+ // The first ten are PreToolUse gate ids as recorded in the session log; the last four name Stop
9
+ // sub-walls that had no id at all until `mode.walls` needed to talk about them.
10
+ export const WALLS = [
11
+ "frozen-record",
12
+ "protected-file",
13
+ "plan-gate",
14
+ "changelog",
15
+ "bookkeeping",
16
+ "package-table",
17
+ "foreign-claim",
18
+ "branch-worktree",
19
+ "live-command",
20
+ "commit-claim",
21
+ "queue",
22
+ "open-denials",
23
+ "ledger",
24
+ "changelog-entry",
25
+ ];
26
+
27
+ // The build graph's own denials are deliberately absent: they are not skippable, because they
28
+ // are the graph enforcing itself. A name in this list is a name a graph can switch off.
29
+
30
+ // `allowlist` is null when no graph declares one — every wall on, today's behaviour.
31
+ export function wallEnabled(allowlist, name) {
32
+ if (!Array.isArray(allowlist)) return true;
33
+ return allowlist.includes(name);
34
+ }
@@ -60,7 +60,7 @@ Test packages install at `latest` — outside the approved-packages pin set. Fak
60
60
 
61
61
  - **Suite, not scripts.** Tests live in the canonical test tree — `tests/` mirroring `src/`, or the convention the repo already uses — named `<subject>.test.ts`; shared fixtures, factories, and helpers in `tests/helpers/` (or the suite's existing shared home).
62
62
  - **Wire the canonical command.** `"test"` in `package.json` runs the whole unit suite (`vitest run`); e2e under `"test:e2e"`. A repo where `pnpm test` / `npm test` doesn't run the suite is misconfigured — fix the wiring, **never** route around it.
63
- - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob.
63
+ - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob. The e2e config **owns its world** — it builds, seeds, and boots the app itself (a `webServer` block or equivalent), so a journey passes from a cold shell like any other test and "the world was not running" is never why one did not run.
64
64
  - **The real-renderer stratum is a third tree** — `tests/rendered/` under the unit runner's *browser mode*, its own config, kept to geometry regressions that shipped and the composition assertions a component library cannot make alone. It is not a second component suite; anything a simulated DOM can prove stays in `tests/component/`.
65
65
  - **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via `supertest` — over mocks; mock **only** true externals (network via `msw`, time via the runner's fake timers).
66
66
  - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone, in any order, **and from a cold shell** — never against a hand-started dev server (Workers: `@cloudflare/vitest-pool-workers`; Express: `supertest` against the app instance).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: testing
3
- description: "The test-lifecycle skill — three modes. Plan-gate mode: after `boundary` passes, derives the tests a plan owes and writes the Test Obligations ledger into the plan (a row per behavior: layer, target file, oracle, fixtures); writes no test files. Placement mode: layered test strategy — which ownership strata the application has, where a test lives and what it may assert; use for any testing-strategy, test-architecture, test-pyramid, or 'unit/integration/e2e?' question, even if 'layer' is never said. Gate mode: executes the verification gate — derives the behavioral checklist, drives the real surface (browser automation, the real CLI, HTTP; the `/test` runner where wired), pins durable behaviors, discharges every ledger row. Triggers on 'test plan', 'testing strategy', 'test architecture', 'test layers', 'where should this test live', 'run the verification gate'."
3
+ description: "The test-lifecycle skill — three modes. Plan-gate mode: after `boundary` passes, derives the tests a plan owes and writes the Test Obligations ledger into the plan (a row per behavior: layer, target file, oracle, fixtures); writes no test files. Placement mode: layered test strategy — which ownership strata the application has, where a test lives and what it may assert (on a rendered surface every user-observable promise lives in the surface's journey; component tests are residual); use for any testing-strategy, test-architecture, test-pyramid, or 'unit/integration/e2e?' question, even if 'layer' is never said. Gate mode: executes the verification gate — derives the behavioral checklist, drives the real surface (browser automation, the real CLI, HTTP; the `/test` runner where wired), pins durable behaviors, discharges every ledger row. Triggers on 'test plan', 'testing strategy', 'test architecture', 'test layers', 'where should this test live', 'run the verification gate'."
4
4
  ---
5
5
 
6
6
  # Testing — Ledger, Layers, Gate
@@ -30,9 +30,9 @@ You derive the tests an implementation plan owes and pin them into the plan as a
30
30
 
31
31
  ### Layer assignment
32
32
 
33
- Assign each behavior the **cheapest layer that can prove it**, then name the oracle. The ledger altitudes are `unit` / `component` / `integration` / `e2e` (strata mapping: § Bridge below); **integration is the default for server behavior** — a route/handler against a real in-memory datastore via the app instance. Error paths, races, and idempotency are obligations too, and belong at **integration** with injected faults (network mock, fake timers, forced constraint) — never e2e.
33
+ Assign each behavior the **cheapest layer that can prove it**, then name the oracle. The ledger altitudes are `unit` / `component` / `integration` / `e2e` (strata mapping: § Bridge below). **Integration is the default for server behavior** — a route/handler against a real in-memory datastore via the app instance; server-side error paths, races, and idempotency belong there with injected faults (network mock, fake timers, forced constraint). **A journey is the default for a rendered surface** — any promise a user could observe in a real browser (what a screen shows over real rows, what a click changes, where navigation lands and what the URL carries, what a refused or failed request shows) is owed at layer 8 as `extend journey <surface>`, never as a component test; the surface's own fault paths (a 500 on load, a 400 on submit) stay in the journey too, injected at the app's own endpoint from the browser. What a rendered surface still owes below the browser is enumerated in § Placement discipline: a pure derivation with real branches (layer 6), a regression pin for a state the journey world cannot reach (layer 7), the wire's error taxonomy (layer 5), and geometry that shipped wrong (7b).
34
34
 
35
- **The e2e altitude gate:** an e2e obligation is earned **only** when the behavior genuinely spans processes no single-process test can exercise together. Density is one scenario per cross-process journey, not per feature — prefer **"extend scenario `<name>`"** over **"new scenario"**. **Refuse to emit any e2e obligation while the project's e2e smoke gate is not wired in CI** — emit a **blocked** row naming the missing gate instead.
35
+ **The journey world is a precondition, not an excuse.** A rendered surface owes its journeys whether or not the repo's e2e config boots today. If it does not — no config, a world pointed at a deleted package, a job parked in CI — the plan's **first** obligation row is wiring it (`e2e · <config> · the world builds, seeds, boots and one journey passes from a cold shell`), and every journey row names it as a dependency. Emitting `blocked` rows in place of journeys and routing the promises down to component tests is the failure this rule exists to stop: a broken world that nothing obliges anyone to fix stays broken, and every UI promise for months lands in a simulated DOM. Density is one spec file per surface and one `test()` per user journey, carrying as many assertions as the journey has observable steps — prefer **"extend journey `<surface>`"** over a new file.
36
36
 
37
37
  ### Run (plan-gate)
38
38
 
@@ -41,7 +41,7 @@ Assign each behavior the **cheapest layer that can prove it**, then name the ora
41
41
  1. **Build the behavior list** from Proposed Changes + the LLD's promises + HIGH/CRITICAL boundary findings. Note compiler-guarded behaviors — they owe **no** test.
42
42
  2. **Assign layer + oracle** per behavior; cheapest proving layer wins.
43
43
  3. **Map to a target file** per `testing.md`; name fixtures/factories and fault injection; use the repo-standard fake-data source.
44
- 4. **Apply the e2e altitude gate**; downgrade everything else to integration.
44
+ 4. **Route by surface**: a user-observable promise on a rendered surface → `e2e` (its surface's journey, with the world-wiring row first if the world does not boot); server behavior → `integration`; only what § Placement discipline keeps below the browser → `component` / `unit`.
45
45
  5. **Emit the Test Obligations section into the plan** (an executor dispatched with only the plan sees nothing else), plus a one-line chat summary:
46
46
 
47
47
  ```
@@ -76,9 +76,9 @@ The maximum form, for a full-stack application. Each layer names what only it ca
76
76
  | 4 | **Contract artifact** | Nothing behavioral. Typed fixtures / interception handlers **compiled from the provider's own types**, exported for every consumer-side test | The typechecker — drift from the provider is a compile error | No runner; a module the provider owns |
77
77
  | 5 | **Client data** | The consumer's half of the wire: error taxonomy, parsing, byte fidelity, stream reassembly, retry/abort | Intercepted network serving layer-4 artifacts; no DOM | `msw`-class interception |
78
78
  | 6 | **View logic** | Pure presentation derivations: grouping, ordering, status vocabularies, formatting | Table-driven equality | Plain runner |
79
- | 7 | **Component / surface** | Interaction orchestration — what a user action changes on screen: buffer lifecycle, optimistic states, error surfaces | Rendered output a user can observe; network answered by layer 4 | DOM testing-library class + interception |
79
+ | 7 | **Component / surface** | **Residual on any stack that has layer 8.** Regression pins for what the journey world cannot reach: a state only a fixture the world lacks can produce, a flow behind an external system (a GitHub installation, an AI reply), an orchestration detail no user step exposes (a debounce, a buffer surviving an abort) | Rendered output a user can observe; network answered by layer 4 | DOM testing-library class + interception — never the default home of a UI promise |
80
80
  | 7b | **Rendered composition** | Defects only a real rendering engine produces: geometry (overlap, clipping, a container that grows when a panel opens), the theme cascade where your tokens meet a component library's, focus order and accessible names through a third-party primitive | Measurement in a real browser engine — `getBoundingClientRect`, computed style, the accessibility tree | Real-renderer component runner (the unit runner's browser mode), **never** a simulated DOM |
81
- | 8 | **System smoke** | Composition only — the layers are actually stacked: the build serves, auth flows end to end, one write round-trips | A real browser or real binary against the really-running app | Browser/process driver class; 3–5 journeys, zero variants |
81
+ | 8 | **Journeys** | Everything a user can observe on a rendered surface: what a screen shows over real rows, what an action changes, where navigation lands and what the URL carries, what a refused or failed request shows, a create-then-delete round trip — and composition: the build serves, auth flows end to end | A real browser (or the real binary/wire, for a stack that renders nothing) against the really-running app over a seeded world; faults injected at the app's own endpoints | Browser/process driver class; **one spec file per surface**, one test per user journey, variants wherever a user can see them |
82
82
 
83
83
  Layer 4 is the hinge. Every layer ≥5 runs on the assumption that layers 1–3 are green — and the artifact makes that assumption *enforceable*: when the provider's response type changes, the fixture stops compiling, and every consumer test fails at typecheck before it runs. A consumer-side test that hand-writes a wire response has smuggled unverified layer-3 knowledge upward; route it through the artifact.
84
84
 
@@ -88,7 +88,7 @@ Layer 4 is the hinge. Every layer ≥5 runs on the assumption that layers 1–3
88
88
 
89
89
  | Application shape | Strata present |
90
90
  | --- | --- |
91
- | Full-stack web app | all eight |
91
+ | Full-stack web app | all eight — 8 is the home of every UI promise, 7 is residual |
92
92
  | Backend-only service | 1, 2, 3, 8 |
93
93
  | CLI | 2, 3 (the CLI's argv/stdout contract), 8 |
94
94
  | Frontend on a third-party API | 4 (derived from the API's published spec), 5, 6, 7, 8 |
@@ -98,16 +98,16 @@ Derive the stack first, then place tests: a placement argument that starts "this
98
98
 
99
99
  ### Placement discipline
100
100
 
101
- - **Two conditions put a test below a real browser; both must hold.** *(a)* Its assertion names a **product promise**, not a construction detail — litmus: would it survive a total visual redesign that changes no behavior? `not.toHaveProperty("taskMode")` survives; `classList.contains("w-24")` does not. *(b)* The defect it catches can **originate below the layout engine** — a wrong wire path, a dropped field, a state-machine bug, a wrong string. Fail either and the honest home is 7b or 8.
101
+ - **Three conditions put a UI test below a real browser; all must hold.** *(a)* Its assertion names a **product promise**, not a construction detail — litmus: would it survive a total visual redesign that changes no behavior? `not.toHaveProperty("taskMode")` survives; `classList.contains("w-24")` does not. *(b)* The defect it catches can **originate below the layout engine** — a wrong wire path, a dropped field, a state-machine bug, a wrong string. *(c)* **The surface's journey cannot reach it** — the state needs a fixture the seeded world lacks, an external system, or an orchestration detail no user step exposes. Fail (a) or (b) and the honest home is 7b; fail (c) and the home is the journey, as one more assertion.
102
102
  - **Geometry may never be asserted in a runner without a layout engine.** There is no valid proxy: a class token is not a width, and a test that pretends otherwise is simultaneously brittle and blind. When the only available runner simulates the DOM, the behavior is driven and pinned at 7b — never approximated.
103
103
  - **An assertion provable at two layers is misplaced — it belongs at the lower one.** The duplication check is mechanical, not judgment.
104
- - **Behavior variants sink to the cheapest layer that owns them** — almost always 2 or 6. A variant at layer 3 pays wire tax on a domain test; a variant at layer 8 is a placement failure below, by definition.
105
- - **One representative path upward.** Layer 3 keeps one happy path per route plus exhaustive coverage of *contract edges only*; layer 8 keeps 3–5 journeys total.
104
+ - **Behavior variants sink to the cheapest layer that owns them** — a derivation's variants to 6, a rule's to 2. A variant a user can *see* (a second sort order, a filtered roster, a refused form) belongs in the journey that shows it, as one more assertion, not as a component case. A variant at layer 3 pays wire tax on a domain test.
105
+ - **One representative path upward for the server; the whole surface for the browser.** Layer 3 keeps one happy path per route plus exhaustive coverage of *contract edges only*. Layer 8 keeps one spec per surface covering everything a user can reach in the seeded world — the count is set by the surface, never by a cap. A cap on journeys is what pushes UI promises into a simulated DOM, and a simulated DOM talks to a hand-written fake of the wire, so a route rename or an envelope change passes it green.
106
106
  - **7b stays small by construction.** It holds geometry regressions that actually shipped and the handful of composition assertions a component library's contract cannot make alone (the theme cascade, the accessible name through its primitive). It is not a second component suite — a behavior that a simulated DOM can prove has no business paying a browser's price.
107
107
  - **External systems enter as modeled fakes, not spies.** A fake implements the port's semantics so tests assert resulting state; a spy only proves methods were called, which passes just as happily with the bytes swapped.
108
108
  - **Failure localization is the payoff to protect.** One defect turns exactly one layer red, at its owner. When a single change reddens several layers, at most one of those tests was correctly placed — treat the rest as findings.
109
109
 
110
- Layering makes cost **additive** — a new API behavior costs one layer-2 test and maybe a layer-3 edge, zero frontend tests; a new UI state costs one layer-7 test, zero backend tests. A flat suite pays N×M, because every top-layer path implicitly re-verifies every behavior beneath it.
110
+ Layering makes cost **additive** — a new API behavior costs one layer-2 test and maybe a layer-3 edge, zero frontend tests; a new UI state costs one more assertion in its surface's journey, zero backend tests. A flat suite pays N×M, because every top-layer path implicitly re-verifies every behavior beneath it.
111
111
 
112
112
  ### The tree
113
113
 
@@ -119,10 +119,10 @@ tests/
119
119
  contract/ L4 — no runner; typed fixtures + interception handlers
120
120
  client/ L5 — plain unit runner
121
121
  view/ L6 — plain unit runner
122
- component/ L7 — unit runner + DOM environment (per-file opt-in)
122
+ component/ L7 — unit runner + DOM environment (per-file opt-in); residual where e2e/ exists
123
123
  rendered/ L7b — the same runner in browser mode; a real engine, kept small
124
124
  helpers/ shared seeds, fakes, and builders — owned by no stratum
125
- e2e/ L8 — its own config, never mixed into the unit runner's glob
125
+ e2e/ L8 — one spec per surface over a seeded world; its own config, never mixed into the unit runner's glob
126
126
  ```
127
127
 
128
128
  - **A stratum the app lacks gets no directory** — an empty `component/` in a backend service is a lie about the app's shape.
@@ -147,7 +147,7 @@ When writing or discharging a ledger row, name the stratum, not just the altitud
147
147
 
148
148
  1. **Derive the stack** from the application's real boundaries (storage? published wire? separate client? DOM?).
149
149
  2. **Place each behavior** by the litmus; name stratum, oracle, technology class; take the concrete framework and file placement from `testing.md`.
150
- 3. **Flag violations, don't just place** — assertions provable lower, variants above 2/6, hand-written wire fixtures above layer 4, spies where fakes should model state, layer-8 variants.
150
+ 3. **Flag violations, don't just place** — assertions provable lower, derivation variants above 6, hand-written wire fixtures above layer 4, spies where fakes should model state, and UI promises pinned at 7 that the surface's journey can reach.
151
151
  4. **Report as a stratum map** — one line per stratum: exists/absent (why), what lives there, what is misplaced and where it moves. For a plan, feed the strata into the ledger's rows.
152
152
 
153
153
  ---
@@ -168,7 +168,7 @@ Runs as the tail of every build pass — the session opens it the moment the uni
168
168
  Cheap oracles have not stopped mattering — they stopped being a gate you pass before looking. Suites, the CLI binary, and HTTP against the running worker run in the same pass and are what the drive's findings get **pinned into** (rung 3). On a surface with no rendering — a CLI, a service, a library — this rung is its own real binary or real wire, and the visual clause simply does not apply.
169
169
 
170
170
  **A failing checklist assertion is never softened to make a run green** — record the defect, send it to the backlog.
171
- 3. **Pin the durable behaviors — once, against the driven code** — as suite tests per `testing.md`, naming each test's stratum (§ Bridge). Pins are written after the drive and the fixes, never beside the first attempt: a pin written earlier is rewritten on every iteration and proves only that its author and the code agree. **Pinning is written inline, by the agent that drove** — it alone knows what the drive found and what each pin must therefore assert; a sub-agent briefed from the ledger row reproduces the row, not the finding. **A pinned test must pass from a cold shell** — `pnpm test` with no dev server hand-started, no seeded state assumed beyond what the suite builds for itself. A test whose oracle is "fetch the server I happen to have running" is a driven check wearing a test's name: it goes red the moment the session ends (on Workers, `@cloudflare/vitest-pool-workers` is the self-contained harness; an Express app is driven via `supertest`, never a port). Before discharging, run the suite once from a clean shell — a suite that needs the session's environment discharges nothing. A test is a permanent maintenance cost, bought deliberately:
171
+ 3. **Pin the durable behaviors — once, against the driven code** — as suite tests per `testing.md`, naming each test's stratum (§ Bridge). **On a rendered surface the pin is the surface's journey first**: the drive just performed *is* the journey, so write it down as the test against the seeded world, and reach for a component test only where § Placement discipline's third condition holds. Pins are written after the drive and the fixes, never beside the first attempt: a pin written earlier is rewritten on every iteration and proves only that its author and the code agree. **Pinning is written inline, by the agent that drove** — it alone knows what the drive found and what each pin must therefore assert; a sub-agent briefed from the ledger row reproduces the row, not the finding. **A pinned test must pass from a cold shell** — `pnpm test` with no dev server hand-started, no seeded state assumed beyond what the suite builds for itself. A test whose oracle is "fetch the server I happen to have running" is a driven check wearing a test's name: it goes red the moment the session ends (on Workers, `@cloudflare/vitest-pool-workers` is the self-contained harness; an Express app is driven via `supertest`, never a port). Before discharging, run the suite once from a clean shell — a suite that needs the session's environment discharges nothing. A test is a permanent maintenance cost, bought deliberately:
172
172
 
173
173
  | Earns a suite test | Verified by driving alone |
174
174
  | --- | --- |
@@ -176,9 +176,10 @@ Runs as the tail of every build pass — the session opens it the moment the uni
176
176
  | A bug that occurred once — its regression test | A one-off migration or backfill's output |
177
177
  | **A layout defect that shipped** — its geometry pin, at 7b, measured in a real engine | Layout a judged drive has looked at and found correct — looking is the proof, and a pin buys nothing until something breaks |
178
178
  | An error path, race, or idempotency guarantee | Behavior a type or the compiler already makes unshippable |
179
+ | A user-observable step of a surface — as an assertion in its journey | A same-branch table row: an `it.each` earns one row per branch or boundary, never one per literal |
179
180
 
180
- A driven check is evidence, not an artifact — it proves the change works today and leaves nothing behind; that is why this rung exists.
181
- 4. **Run the affected suite**, plus the project's e2e smoke gate where one is wired.
181
+ A driven check is evidence, not an artifact — it proves the change works today and leaves nothing behind; that is why this rung exists. Two hygiene rules hold at every pin: **a suite whose subject is no longer reachable from the product's entry points is deleted with the code path**, never repointed to keep it green; and **a case that asserts nothing beyond "it did not throw" is not a pin** — give it an assertion on resulting state or delete it.
182
+ 4. **Run the affected suite and the affected surface's journeys from a cold shell** — the e2e config builds, seeds and boots its own world, so "needs the world running" is never a reason a journey did not run.
182
183
  5. **Discharge every ledger row into exactly one outcome**: **satisfied** (test green, or driven check passed), **owes nothing** (with the reason — compiler-guarded, covered elsewhere), or **blocked** (missing infrastructure named, row converted to a backlog item). **Open by re-reading the ledger from the plan file on disk, never from conversation memory** — a context-held copy is how rows get missed. **Write each row's outcome into its `Outcome` cell in the plan file** — exactly `satisfied`, `owes nothing — {reason}`, or `blocked → backlog` — because the Stop wall and CI read that cell, and a row left unexamined blocks completion.
183
184
  6. **Report results honestly, including failures**; record which test now guards each checklist section (`Pinned:` traces) and each section's `AC:` line where the repo carries a keyed acceptance-criteria source, closing with the coverage count — `AC coverage: {n}/{m}; uncovered: {keys} → backlog`.
184
185
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "etna",
3
3
  "name": "etna",
4
- "version": "1.5.19",
4
+ "version": "1.5.20",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -10,5 +10,5 @@
10
10
  "seeds": [],
11
11
  "tools": [],
12
12
  "docsRoot": "docs",
13
- "digest": "4cf950471dd8"
13
+ "digest": "4c5fe7575b80"
14
14
  }
@@ -60,7 +60,7 @@ Test packages install at `latest` — outside the approved-packages pin set. Fak
60
60
 
61
61
  - **Suite, not scripts.** Tests live in the canonical test tree — `tests/` mirroring `src/`, or the convention the repo already uses — named `<subject>.test.ts`; shared fixtures, factories, and helpers in `tests/helpers/` (or the suite's existing shared home).
62
62
  - **Wire the canonical command.** `"test"` in `package.json` runs the whole unit suite (`vitest run`); e2e under `"test:e2e"`. A repo where `pnpm test` / `npm test` doesn't run the suite is misconfigured — fix the wiring, **never** route around it.
63
- - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob.
63
+ - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob. The e2e config **owns its world** — it builds, seeds, and boots the app itself (a `webServer` block or equivalent), so a journey passes from a cold shell like any other test and "the world was not running" is never why one did not run.
64
64
  - **The real-renderer stratum is a third tree** — `tests/rendered/` under the unit runner's *browser mode*, its own config, kept to geometry regressions that shipped and the composition assertions a component library cannot make alone. It is not a second component suite; anything a simulated DOM can prove stays in `tests/component/`.
65
65
  - **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via `supertest` — over mocks; mock **only** true externals (network via `msw`, time via the runner's fake timers).
66
66
  - **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone, in any order, **and from a cold shell** — never against a hand-started dev server (Workers: `@cloudflare/vitest-pool-workers`; Express: `supertest` against the app instance).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.20.11",
3
+ "version": "0.20.13",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "7d6e2acfe663"
45
+ "etnaFingerprint": "0eb49820003e"
46
46
  }