@nurix/etna 0.20.2 → 0.20.3

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 (56) hide show
  1. package/data/catalog/rules/testing.md +13 -9
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/code/CLAUDE.md +5 -1
  4. package/data/harnesses/code/agents/bookkeeper.md +1 -1
  5. package/data/harnesses/code/agents/issue-bridge.md +16 -0
  6. package/data/harnesses/code/agents/maintenance.md +36 -3
  7. package/data/harnesses/code/docs/README.md +1 -1
  8. package/data/harnesses/code/docs/history/backlog.md +1 -1
  9. package/data/harnesses/code/docs/structure.md +2 -2
  10. package/data/harnesses/code/harness.json +12 -2
  11. package/data/harnesses/code/hooks/awareness.mjs +75 -14
  12. package/data/harnesses/code/hooks/compliance.mjs +25 -17
  13. package/data/harnesses/code/hooks/lib/exempt.mjs +7 -5
  14. package/data/harnesses/code/hooks/lib/frozen.mjs +9 -6
  15. package/data/harnesses/code/hooks/lib/ledger.mjs +26 -4
  16. package/data/harnesses/code/hooks/lib/ownership.mjs +73 -2
  17. package/data/harnesses/code/hooks/lib/plans.mjs +2 -2
  18. package/data/harnesses/code/hooks/lib/repo.mjs +22 -0
  19. package/data/harnesses/code/hooks/lib/state.mjs +38 -2
  20. package/data/harnesses/code/hooks/lib/sweep.mjs +37 -15
  21. package/data/harnesses/code/rules/testing.md +13 -9
  22. package/data/harnesses/code/skills/codegraph/SKILL.md +57 -0
  23. package/data/harnesses/code/skills/documentation/SKILL.md +12 -5
  24. package/data/harnesses/code/skills/documentation/references/frontmatter.md +29 -4
  25. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +18 -1
  26. package/data/harnesses/code/skills/documentation/templates/feature.md +1 -0
  27. package/data/harnesses/code/skills/documentation/templates/plan.md +2 -0
  28. package/data/harnesses/code/skills/documentation/templates/ux-behavior.md +1 -1
  29. package/data/harnesses/code/skills/issue-bridge/SKILL.md +55 -0
  30. package/data/harnesses/code/skills/issue-bridge/references/trackers/github.md +37 -0
  31. package/data/harnesses/code/skills/issue-bridge/references/trackers/jira.md +40 -0
  32. package/data/harnesses/code/skills/issue-bridge/references/trackers/linear.md +37 -0
  33. package/data/harnesses/code/skills/testing/SKILL.md +20 -4
  34. package/data/harnesses/delivery/agents/maintenance.md +36 -3
  35. package/data/harnesses/delivery/harness.json +4 -2
  36. package/data/harnesses/delivery/skills/documentation/SKILL.md +12 -5
  37. package/data/harnesses/delivery/skills/documentation/references/frontmatter.md +29 -4
  38. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +18 -1
  39. package/data/harnesses/delivery/skills/documentation/templates/feature.md +1 -0
  40. package/data/harnesses/delivery/skills/documentation/templates/plan.md +2 -0
  41. package/data/harnesses/delivery/skills/documentation/templates/ux-behavior.md +1 -1
  42. package/data/harnesses/discover/harness.json +2 -0
  43. package/data/harnesses/discussion/harness.json +2 -0
  44. package/data/harnesses/etna/agents/maintenance.md +36 -3
  45. package/data/harnesses/etna/harness.json +4 -2
  46. package/data/harnesses/etna/rules/testing.md +13 -9
  47. package/data/harnesses/etna/skills/documentation/SKILL.md +12 -5
  48. package/data/harnesses/etna/skills/documentation/references/frontmatter.md +29 -4
  49. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +18 -1
  50. package/data/harnesses/etna/skills/documentation/templates/feature.md +1 -0
  51. package/data/harnesses/etna/skills/documentation/templates/plan.md +2 -0
  52. package/data/harnesses/etna/skills/documentation/templates/ux-behavior.md +1 -1
  53. package/data/harnesses/hr/harness.json +2 -0
  54. package/data/harnesses/kb-agi/harness.json +2 -0
  55. package/lib/install/plan.mjs +22 -1
  56. package/package.json +2 -2
@@ -8,12 +8,18 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
11
- import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT } from "./lib/repo.mjs";
11
+ import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT, docsRoot } from "./lib/repo.mjs";
12
12
  import { openGatedPlans, inPlanScope } from "./lib/plans.mjs";
13
13
  import { stagedExempt } from "./lib/exempt.mjs";
14
14
  import { readQueue, countRows, rowsBlockingCommit, askedThisSession, recordAsked } from "./lib/state.mjs";
15
15
  import { liveRows } from "./lib/sweep.mjs";
16
- import { frozenStatus } from "./lib/frozen.mjs";
16
+ // frozenStatusAtHead, not frozenStatus: a record frozen only in the WORKTREE is a freeze born in the
17
+ // current edit — a plan legitimately flipping to Complete — and denying that walls the completion
18
+ // ceremony itself, stranding the `Completed:` field the very same edit was meant to fill. Only an edit
19
+ // to something HEAD already froze is an evasion. frozen.mjs says exactly this above frozenStatusAtHead;
20
+ // this call site disagreed with it until 2026-08-20, when a verification gate flipped Status, was frozen
21
+ // mid-ceremony, and could not write its own completion date.
22
+ import { frozenStatusAtHead } from "./lib/frozen.mjs";
17
23
  import { undischargedRows } from "./lib/ledger.mjs";
18
24
  import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
19
25
  import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
@@ -50,13 +56,14 @@ function autoResolve(projectDir, sessionId, gate, target) {
50
56
  let protectedCache = null;
51
57
  function protectedPaths(projectDir) {
52
58
  if (protectedCache) return protectedCache;
53
- const text = readSafe(path.join(projectDir, "docs", "operations", "protected-files.md"));
54
- protectedCache = ["AGENTS.md", "docs/operations/protected-files.md", ...backtickTokens(text)];
59
+ const root = docsRoot(projectDir);
60
+ const text = readSafe(path.join(projectDir, root, "operations", "protected-files.md"));
61
+ protectedCache = ["AGENTS.md", `${root}/operations/protected-files.md`, ...backtickTokens(text)];
55
62
  return protectedCache;
56
63
  }
57
64
 
58
65
  function fileGate(projectDir, rel, sessionId) {
59
- const frozen = frozenStatus(projectDir, rel);
66
+ const frozen = frozenStatusAtHead(projectDir, rel);
60
67
  if (frozen.frozen) {
61
68
  return deny(
62
69
  projectDir,
@@ -91,7 +98,7 @@ function fileGate(projectDir, rel, sessionId) {
91
98
  /* presence is a convenience — never let it block an edit on failure */
92
99
  }
93
100
 
94
- const inDocs = rel.startsWith("docs/") || rel.startsWith(".claude/") || rel.endsWith(".md");
101
+ const inDocs = rel.startsWith(`${docsRoot(projectDir)}/`) || rel.startsWith(".claude/") || rel.endsWith(".md");
95
102
  if (!inDocs && SOURCE_EXT.test(rel)) {
96
103
  for (const plan of openGatedPlans(projectDir)) {
97
104
  // The gate binds the plan's own paths, so disjoint plans run in parallel.
@@ -159,14 +166,15 @@ function commitGate(projectDir, sessionId) {
159
166
  .map((s) => s.trim())
160
167
  .filter(Boolean);
161
168
  const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
162
- if (staged.length > 0 && !stagedExempt(staged) && !allRenames) {
163
- if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
169
+ const root = docsRoot(projectDir);
170
+ if (staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
171
+ if (!staged.some((f) => f.startsWith(`${root}/history/changelog/`))) {
164
172
  return deny(
165
173
  projectDir,
166
174
  sessionId,
167
175
  "changelog",
168
176
  "git commit",
169
- "BLOCKED — changelog. This commit stages no `docs/history/changelog/` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run)."
177
+ `BLOCKED — changelog. This commit stages no \`${root}/history/changelog/\` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run).`
170
178
  );
171
179
  }
172
180
  }
@@ -190,8 +198,8 @@ function commitGate(projectDir, sessionId) {
190
198
  sessionId,
191
199
  "bookkeeping",
192
200
  "git commit",
193
- `BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in docs/operations/bookkeeping.md implicate this commit (each re-verified against the repo just now — these are live):\n${lines.join("\n")}\n` +
194
- "Dispatch the bookkeeper, resolve them, or convert to `waived:` with a reason and expiry; a successful retry re-tests everything again." +
201
+ `BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in ${root}/operations/bookkeeping.md implicate this commit (each re-verified against the repo just now — these are live):\n${lines.join("\n")}\n` +
202
+ "Dispatch the bookkeeper to drain them (never drain auto:/note: rows in your own loop; needs-session: rows are yours), or convert to `waived:` with a reason and expiry; a successful retry re-tests everything again." +
195
203
  (unrelated > 0
196
204
  ? ` (${unrelated} further unresolved row(s) implicate nothing staged here — another session's work, and not yours to drain.)`
197
205
  : "")
@@ -322,7 +330,7 @@ function bashGate(projectDir, command, sessionId) {
322
330
  projectDir,
323
331
  sessionId,
324
332
  "live-command",
325
- "Run this live-system command (deploy / remote DB / secrets)? If it's a one-shot migration, its runbook should exist in docs/history/migrations/ first. Asked once per session."
333
+ `Run this live-system command (deploy / remote DB / secrets)? If it's a one-shot migration, its runbook should exist in ${docsRoot(projectDir)}/history/migrations/ first. Asked once per session.`
326
334
  );
327
335
  }
328
336
 
@@ -358,7 +366,7 @@ function bashWriteScreen(projectDir, command, sessionId) {
358
366
  for (const target of bashWriteTargets(command)) {
359
367
  const rel = relIfInside(projectDir, target);
360
368
  if (!rel) continue;
361
- const frozen = frozenStatus(projectDir, rel);
369
+ const frozen = frozenStatusAtHead(projectDir, rel);
362
370
  if (frozen.frozen) {
363
371
  return deny(
364
372
  projectDir,
@@ -408,7 +416,7 @@ function plansCompletedThisSession(projectDir, sessionId) {
408
416
  // existed carry no session and belong to none, so they are skipped rather than assumed.
409
417
  .filter((cols) => sessionId && cols[3] === sessionId)
410
418
  .map((cols) => cols[1])
411
- .filter((p) => p && p.startsWith("docs/history/plans/"))
419
+ .filter((p) => p && p.startsWith(`${docsRoot(projectDir)}/history/plans/`))
412
420
  );
413
421
  const out = [];
414
422
  for (const rel of touchedPlans) {
@@ -425,7 +433,7 @@ function plansCompletedThisSession(projectDir, sessionId) {
425
433
  // for every plan at once.
426
434
  function planHasChangelogEntry(projectDir, planRel) {
427
435
  const slug = path.basename(planRel, ".md");
428
- const dir = path.join(projectDir, "docs", "history", "changelog");
436
+ const dir = path.join(projectDir, docsRoot(projectDir), "history", "changelog");
429
437
  let names = [];
430
438
  try {
431
439
  names = fs.readdirSync(dir);
@@ -452,8 +460,8 @@ function stopGate(projectDir, payload) {
452
460
  return {
453
461
  decision: "block",
454
462
  reason:
455
- `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in docs/operations/bookkeeping.md. ` +
456
- "Dispatch the `bookkeeper` agent to drain them, resolve them now, or convert to `waived:` rows with reason + expiry. " +
463
+ `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in ${docsRoot(projectDir)}/operations/bookkeeping.md. ` +
464
+ "Dispatch the `bookkeeper` agent to drain them — inline resolution is for needs-session: rows only — or convert to `waived:` rows with reason + expiry. " +
457
465
  (counts.expiredWaived > 0
458
466
  ? "An expired waiver is a deferral that ran out — re-judge whether its reason still holds rather than re-waiving by default. "
459
467
  : "") +
@@ -4,6 +4,7 @@
4
4
  // whether the same commit owed an entry.
5
5
 
6
6
  import path from "node:path";
7
+ import { docsRoot } from "./repo.mjs";
7
8
 
8
9
  const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
9
10
  const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
@@ -16,17 +17,18 @@ const HARNESS_FILES = [
16
17
  ".prettierrc.json",
17
18
  ];
18
19
 
19
- export function changelogExemptFile(f) {
20
+ export function changelogExemptFile(projectDir, f) {
21
+ const root = docsRoot(projectDir);
20
22
  return (
21
23
  LOCKFILES.test(path.basename(f)) ||
22
24
  HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
23
25
  HARNESS_FILES.includes(f) ||
24
- f.startsWith("docs/history/changelog/") ||
26
+ f.startsWith(`${root}/history/changelog/`) ||
25
27
  f.includes(".bkp.") ||
26
- f === "docs/history/CHANGELOG.md"
28
+ f === `${root}/history/CHANGELOG.md`
27
29
  );
28
30
  }
29
31
 
30
- export function stagedExempt(files) {
31
- return files.every(changelogExemptFile);
32
+ export function stagedExempt(projectDir, files) {
33
+ return files.every((f) => changelogExemptFile(projectDir, f));
32
34
  }
@@ -7,6 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { spawnSync } from "node:child_process";
10
+ import { docsRoot } from "./repo.mjs";
10
11
 
11
12
  function relPosix(projectDir, filePath) {
12
13
  return path.relative(projectDir, path.resolve(projectDir, filePath)).split(path.sep).join("/");
@@ -30,23 +31,24 @@ function readSafe(projectDir, filePath) {
30
31
  // The predicate. Returns { frozen: boolean, reason?: string }.
31
32
  export function frozenStatus(projectDir, filePath) {
32
33
  const rel = relPosix(projectDir, filePath);
34
+ const root = docsRoot(projectDir);
33
35
  // *.bkp.* siblings are install-time backups, not records — the maintenance fold edits and
34
36
  // deletes them, which a frozen verdict would forbid.
35
37
  if (rel.includes(".bkp.")) return { frozen: false };
36
- if (rel.startsWith("docs/history/changelog/") && rel.endsWith(".md")) {
38
+ if (rel.startsWith(`${root}/history/changelog/`) && rel.endsWith(".md")) {
37
39
  if (isCommitted(projectDir, rel)) {
38
40
  return { frozen: true, reason: "committed changelog entry — immutability begins at commit" };
39
41
  }
40
42
  return { frozen: false };
41
43
  }
42
- if (rel.startsWith("docs/history/plans/") && rel.endsWith(".md")) {
44
+ if (rel.startsWith(`${root}/history/plans/`) && rel.endsWith(".md")) {
43
45
  const text = readSafe(projectDir, rel);
44
46
  if (/^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(text)) {
45
47
  return { frozen: true, reason: "plan with Status: Complete or Abandoned" };
46
48
  }
47
49
  return { frozen: false };
48
50
  }
49
- if (rel.startsWith("docs/history/migrations/") && rel.endsWith(".md")) {
51
+ if (rel.startsWith(`${root}/history/migrations/`) && rel.endsWith(".md")) {
50
52
  const text = readSafe(projectDir, rel);
51
53
  if (/^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(text)) {
52
54
  return { frozen: true, reason: "outcome-stamped migration runbook" };
@@ -62,8 +64,9 @@ export function frozenStatus(projectDir, filePath) {
62
64
  // Only an edit to something HEAD already froze is an evasion.
63
65
  export function frozenStatusAtHead(projectDir, filePath) {
64
66
  const rel = relPosix(projectDir, filePath);
67
+ const root = docsRoot(projectDir);
65
68
  if (rel.includes(".bkp.")) return { frozen: false };
66
- if (rel.startsWith("docs/history/changelog/") && rel.endsWith(".md")) {
69
+ if (rel.startsWith(`${root}/history/changelog/`) && rel.endsWith(".md")) {
67
70
  return frozenStatus(projectDir, filePath); // already keyed on isCommitted
68
71
  }
69
72
  const inRepo = spawnSync("git", ["rev-parse", "--git-dir"], { cwd: projectDir, stdio: "ignore", timeout: 5000 });
@@ -71,10 +74,10 @@ export function frozenStatusAtHead(projectDir, filePath) {
71
74
  const res = spawnSync("git", ["show", `HEAD:${rel}`], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
72
75
  const headText = res.status === 0 ? res.stdout || "" : "";
73
76
  if (!headText) return { frozen: false }; // never committed — a freeze born in this worktree is a ceremony, not an evasion
74
- if (rel.startsWith("docs/history/plans/") && /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(headText)) {
77
+ if (rel.startsWith(`${root}/history/plans/`) && /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(headText)) {
75
78
  return { frozen: true, reason: "plan committed with Status: Complete or Abandoned" };
76
79
  }
77
- if (rel.startsWith("docs/history/migrations/") && /^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(headText)) {
80
+ if (rel.startsWith(`${root}/history/migrations/`) && /^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(headText)) {
78
81
  return { frozen: true, reason: "outcome-stamped migration runbook" };
79
82
  }
80
83
  return { frozen: false };
@@ -8,6 +8,20 @@
8
8
  const OUTCOME = /^(satisfied|owes nothing|blocked)\b/i;
9
9
  const DOC_OUTCOME = /^(updated|owes nothing|blocked)\b/i;
10
10
 
11
+ // Split one table row into trimmed cells, honouring GFM's escaped pipe.
12
+ //
13
+ // A THIRD TRAP OF THE SAME FAMILY, closed 2026-08-21. `\\|` is the only way to write a literal pipe
14
+ // inside a table cell, and splitting on every `|` invents a column — every cell after it shifts
15
+ // left, so the FIXED Outcome index lands on a neighbour and a row plainly reading `satisfied` is
16
+ // reported undischarged. Unlike the two traps below it fails in the FALSE-POSITIVE direction: it
17
+ // blocks a gate that is already satisfied, and the row renders and reads correctly to a human, so
18
+ // the file looks right while the wall insists it is wrong. Split on unescaped pipes, then unescape.
19
+ function splitRow(line) {
20
+ return line
21
+ .split(/(?<!\\)\|/)
22
+ .map((cell) => cell.replace(/\\\|/g, "|").trim());
23
+ }
24
+
11
25
  // The table rows of one `## <heading>` section, split into trimmed cells, plus the Outcome column
12
26
  // index (null when the table predates the column — legacy, out of contract).
13
27
  function sectionTable(planText, heading) {
@@ -20,19 +34,27 @@ function sectionTable(planText, heading) {
20
34
  }
21
35
  const rows = body.filter((line) => /^\s*\|/.test(line));
22
36
  if (rows.length < 3) return null; // header + divider + at least one data row
23
- const header = rows[0].split("|").map((cell) => cell.trim().toLowerCase());
37
+ const header = splitRow(rows[0]).map((cell) => cell.toLowerCase());
24
38
  const col = header.indexOf("outcome");
25
- return { rows: rows.slice(2).map((line) => line.split("|").map((cell) => cell.trim())), col: col === -1 ? null : col };
39
+ return { rows: rows.slice(2).map(splitRow), col: col === -1 ? null : col };
26
40
  }
27
41
 
28
42
  // Row numbers (the `#` column) of every ledger row whose Outcome cell is empty or off-vocabulary.
43
+ // Two traps this closed on 2026-08-20, both found by a real gate:
44
+ // - A row id may carry a suffix. Ledgers legitimately number an inserted row `12b`, and `^\d+$`
45
+ // skipped it — silently, and in the FALSE-NEGATIVE direction: an undischarged `12b` passed the
46
+ // wall unseen. A suffixed id is still a row.
47
+ // - Outcome cells arrive bolded (`**satisfied**`). undischargedDocRows already strips `*` and says
48
+ // so; this one did not, so the two ledgers in the same file disagreed about markdown. Nothing
49
+ // documented the asymmetry, and the vocabulary is about the WORD, not its emphasis.
29
50
  export function undischargedRows(planText) {
30
51
  const table = sectionTable(planText, "Test Obligations");
31
52
  if (!table || table.col === null) return [];
32
53
  const out = [];
33
54
  for (const cells of table.rows) {
34
- if (!/^\d+$/.test(cells[1] || "")) continue;
35
- if (!OUTCOME.test(cells[table.col] || "")) out.push(cells[1]);
55
+ if (!/^\d+[a-z]?$/i.test(cells[1] || "")) continue;
56
+ const outcome = (cells[table.col] || "").replace(/\*/g, "").trim();
57
+ if (!OUTCOME.test(outcome)) out.push(cells[1]);
36
58
  }
37
59
  return out;
38
60
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { docsRoot } from "./repo.mjs";
7
8
 
8
9
  // Read all of stdin synchronously; returns "" if nothing is piped.
9
10
  export function readStdin() {
@@ -116,7 +117,7 @@ export function asList(v) {
116
117
  // Scan docs/features/**/*.md and return one entry per feature doc that declares owns:.
117
118
  // Shape: { doc (repo-relative, /-separated), owns[], module, keywords[], status }.
118
119
  export function scanFeatureDocs(projectDir) {
119
- const featuresDir = path.join(projectDir, "docs", "features");
120
+ const featuresDir = path.join(projectDir, docsRoot(projectDir), "features");
120
121
  // *.bkp.* files are install-time backups awaiting the maintenance fold — a backed-up feature
121
122
  // doc still carries owns: frontmatter, and scanning it would double-own every path it names.
122
123
  const files = listMarkdown(featuresDir).filter((f) => !f.includes(".bkp."));
@@ -147,7 +148,7 @@ export function scanFeatureDocs(projectDir) {
147
148
  export function buildIndex(projectDir) {
148
149
  // Total markdown under docs/features/, counted independently of scanFeatureDocs — which
149
150
  // drops a doc carrying no owns:, making a parse failure and a doc that owns nothing look alike.
150
- const total = listMarkdown(path.join(projectDir, "docs", "features")).length;
151
+ const total = listMarkdown(path.join(projectDir, docsRoot(projectDir), "features")).length;
151
152
  const docs = scanFeatureDocs(projectDir);
152
153
  const lines = [];
153
154
  let deprecated = 0;
@@ -177,6 +178,76 @@ export function buildIndex(projectDir) {
177
178
  return header + lines.join("\n") + footer;
178
179
  }
179
180
 
181
+ // Scan every doc that declares a doc→doc edge and return the edges, resolved.
182
+ // Shape: { from, kind ("verifies"|"applies"), to, missing (the target file is absent) }.
183
+ //
184
+ // EVERY EDGE IS DECLARED AT THE CONSUMING END — a checklist names what it verifies, a feature names
185
+ // the doctrine it obeys — so this walks the consumers and never looks for a hand-authored inverse.
186
+ // A doc listing its own dependents is a defect the graph is built to make unnecessary.
187
+ export function scanDocEdges(projectDir) {
188
+ const docsDir = path.join(projectDir, docsRoot(projectDir));
189
+ const files = listMarkdown(docsDir).filter((f) => !f.includes(".bkp."));
190
+ const edges = [];
191
+ for (const file of files) {
192
+ let text;
193
+ try {
194
+ text = fs.readFileSync(file, "utf8");
195
+ } catch (e) {
196
+ continue;
197
+ }
198
+ const block = frontmatter(text);
199
+ if (!block) continue;
200
+ const from = path.relative(projectDir, file).split(path.sep).join("/");
201
+ for (const kind of ["verifies", "applies"]) {
202
+ for (const to of asList(field(block, kind))) {
203
+ const target = to.toString().trim();
204
+ if (!target) continue;
205
+ edges.push({
206
+ from,
207
+ kind,
208
+ to: target,
209
+ missing: !fs.existsSync(path.join(projectDir, target)),
210
+ });
211
+ }
212
+ }
213
+ }
214
+ return edges;
215
+ }
216
+
217
+ // Build the inverse-edge index: what each doc is verified by, and what obeys it.
218
+ //
219
+ // THE INVERSE IS GENERATED, NEVER AUTHORED. Read this to answer "if I change this doctrine, what
220
+ // is bound by it?" and "if I change this surface, which checklist proves it?" — the two questions
221
+ // a prose link cannot answer, and the reason the edges are frontmatter rather than sentences.
222
+ // A doc with no inbound edge is reachable by nothing: no change implicates it, so it rots without
223
+ // the rot being detectable. The footer counts those, because that count IS the health metric.
224
+ export function buildEdgeIndex(projectDir) {
225
+ const edges = scanDocEdges(projectDir);
226
+ if (edges.length === 0) return "";
227
+ const inverse = new Map();
228
+ for (const e of edges) {
229
+ if (!inverse.has(e.to)) inverse.set(e.to, []);
230
+ inverse.get(e.to).push(e);
231
+ }
232
+ const lines = [];
233
+ for (const [to, list] of [...inverse].sort((a, b) => a[0].localeCompare(b[0]))) {
234
+ const verifiedBy = list.filter((e) => e.kind === "verifies").map((e) => e.from);
235
+ const appliedBy = list.filter((e) => e.kind === "applies").map((e) => e.from);
236
+ const parts = [];
237
+ if (verifiedBy.length) parts.push(`verified by ${verifiedBy.sort().join(", ")}`);
238
+ if (appliedBy.length) parts.push(`applied by ${appliedBy.sort().join(", ")}`);
239
+ const absent = list.some((e) => e.missing) ? " ← TARGET MISSING" : "";
240
+ lines.push(`${to} ← ${parts.join(" · ")}${absent}`);
241
+ }
242
+ const dangling = edges.filter((e) => e.missing).length;
243
+ const header =
244
+ "# Doc edge index (generated) — doc ← who verifies it · who is bound by it\n" +
245
+ "# Declared once at the consuming end (verifies:/applies: frontmatter); this inverse is derived.\n" +
246
+ "# Before changing a doc, read what points AT it — those are the docs the change implicates.\n";
247
+ const footer = dangling > 0 ? `\n# (${dangling} edge(s) name a doc that does not exist — fix the frontmatter)` : "";
248
+ return header + lines.join("\n") + footer;
249
+ }
250
+
180
251
  // Compile one owns:-style glob (`**` any depth, `*` within a segment) to a RegExp
181
252
  // over /-separated repo-relative paths. `a/**` also matches `a` itself.
182
253
  export function globToRegExp(glob) {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
- import { readSafe, backtickTokens } from "./repo.mjs";
7
+ import { readSafe, backtickTokens, docsRoot } from "./repo.mjs";
8
8
  import { globToRegExp } from "./ownership.mjs";
9
9
 
10
10
  // A plan's scope: path-shaped backticked tokens in its `## Proposed Changes` section.
@@ -31,7 +31,7 @@ export function inPlanScope(rel, scope) {
31
31
  // Every plan in the folder, parsed once: { file, status, gates, scope, hasLedger,
32
32
  // hasDocLedger, ledgerRows }. `gates` is null on legacy plans (pre-Gates format).
33
33
  export function listPlans(projectDir) {
34
- const dir = path.join(projectDir, "docs", "history", "plans");
34
+ const dir = path.join(projectDir, docsRoot(projectDir), "history", "plans");
35
35
  let entries;
36
36
  try {
37
37
  entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
@@ -14,6 +14,28 @@ export function readSafe(file) {
14
14
  }
15
15
  }
16
16
 
17
+ // The docs tree root for a project: <projectDir>/.claude/harness.json's `docsRoot` key when
18
+ // present and non-empty, else "docs". A repo that already owns "docs/" upstream (the Superset
19
+ // field trial) renames the harness's tree to "docs-nurix/" and declares it there — every hook
20
+ // and CI check reads the root through here rather than hardcoding the segment. Tolerant: a
21
+ // missing file, an unreadable one, or malformed JSON all fall through to the default.
22
+ // Memoized per projectDir — hook processes are short-lived, so this only avoids re-reading the
23
+ // marker file more than once within a single invocation, never caches across runs.
24
+ const docsRootCache = new Map();
25
+ export function docsRoot(projectDir) {
26
+ if (docsRootCache.has(projectDir)) return docsRootCache.get(projectDir);
27
+ let root = "docs";
28
+ try {
29
+ const marker = JSON.parse(readSafe(path.join(projectDir, ".claude", "harness.json")));
30
+ const declared = marker && typeof marker.docsRoot === "string" ? marker.docsRoot.trim().replace(/\/+$/, "") : "";
31
+ if (declared) root = declared;
32
+ } catch (e) {
33
+ /* missing, unreadable, or malformed marker — the default stands */
34
+ }
35
+ docsRootCache.set(projectDir, root);
36
+ return root;
37
+ }
38
+
17
39
  // Repo-relative /-separated path, or null when the target lies outside the project.
18
40
  export function relIfInside(projectDir, filePath) {
19
41
  if (!filePath) return null;
@@ -17,8 +17,10 @@
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
  import { globToRegExp } from "./ownership.mjs";
20
+ import { docsRoot } from "./repo.mjs";
20
21
 
21
- export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
22
+ // Relative to the docs root (lib/repo.mjs#docsRoot) — never a bare "docs/..." literal.
23
+ export const QUEUE_PATH = ["operations", "bookkeeping.md"];
22
24
 
23
25
  export const QUEUE_HEADER = `# Bookkeeping
24
26
 
@@ -26,7 +28,7 @@ The shared work queue keeping documentation and code in sync — a queue, never
26
28
  `;
27
29
 
28
30
  export function queueFile(projectDir) {
29
- return path.join(projectDir, ...QUEUE_PATH);
31
+ return path.join(projectDir, docsRoot(projectDir), ...QUEUE_PATH);
30
32
  }
31
33
 
32
34
  // --- The write cell -------------------------------------------------------
@@ -351,6 +353,40 @@ export function recordAsked(projectDir, sessionId, key) {
351
353
  fs.appendFileSync(path.join(stateDir(projectDir), "asked.tsv"), `${sessionId}\t${key}\n`);
352
354
  }
353
355
 
356
+ // Repo-scoped decision memory — the sibling of askedThisSession, and deliberately a different
357
+ // LIFETIME. `askedThisSession` forgets at the session boundary, which is right for a gate that may
358
+ // legitimately re-fire tomorrow; a decision like "do not install this tool here" must outlive the
359
+ // session or the user is asked forever, which is the whole complaint the opt-in flow answers.
360
+ // Records live in .claude/state/decisions.tsv (machine-local, self-ignored by stateDir) — whether a
361
+ // binary is installed is a property of the MACHINE, not of the repo, so one developer's answer must
362
+ // not bind a teammate on another box.
363
+ //
364
+ // FAIL OPEN TOWARD ASKING. Every unreadable path — no file, no key, malformed line, unrecognized
365
+ // value — returns undefined, never "declined". The asymmetry is the point: a wrong "undecided"
366
+ // costs one prompt, while a wrong "declined" silently disables a capability forever with no error
367
+ // to observe and no way for the user to tell it apart from the tool simply never helping.
368
+ const DECISIONS = ["accepted", "declined"];
369
+
370
+ export function readDecision(projectDir, key) {
371
+ try {
372
+ const text = fs.readFileSync(path.join(projectDir, ".claude", "state", "decisions.tsv"), "utf8");
373
+ // Last write wins: the file is append-only, so a later answer supersedes an earlier one.
374
+ let found;
375
+ for (const line of text.split("\n")) {
376
+ const [k, v] = line.split("\t");
377
+ if (k === key && DECISIONS.includes(v)) found = v;
378
+ }
379
+ return found;
380
+ } catch (e) {
381
+ return undefined;
382
+ }
383
+ }
384
+
385
+ export function recordDecision(projectDir, key, value) {
386
+ if (!key || !DECISIONS.includes(value)) return;
387
+ fs.appendFileSync(path.join(stateDir(projectDir), "decisions.tsv"), `${key}\t${value}\n`);
388
+ }
389
+
354
390
  // Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
355
391
  //
356
392
  // THE SESSION ID IS THE FOURTH COLUMN, and it is what lets a reader tell one session's edits from
@@ -20,7 +20,7 @@ import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { spawnSync } from "node:child_process";
22
22
  import { globToRegExp, listRepoFiles } from "./ownership.mjs";
23
- import { readSafe } from "./repo.mjs";
23
+ import { readSafe, docsRoot } from "./repo.mjs";
24
24
  import { resolveRows } from "./state.mjs";
25
25
  import { rejectedPackages } from "./approved.mjs";
26
26
 
@@ -72,24 +72,46 @@ export const ROW_VERIFIERS = {
72
72
  return res.status === 0;
73
73
  },
74
74
 
75
- // "<src> changed; owning doc <doc> may need an update" — dead once the doc is newer than the
76
- // source that implicated it, which is the same freshness test the drift report already applies.
75
+ // "<src> changed; owning doc <doc> may need an update" — dead once the doc's PROSE has actually
76
+ // changed in a commit since the source's last one.
77
+ //
78
+ // NOT MTIME, WHICH IS NOT EVIDENCE OF ANYTHING. This compared `statSync().mtimeMs` and resolved
79
+ // the row whenever the doc's file was newer — so a formatting pass, a `git checkout`, a rebase, a
80
+ // stash pop, or a clone (which stamps every file at once, making the order arbitrary) all read as
81
+ // "the doc was updated". It fired for real: a one-line frontmatter addition across 33 owned docs
82
+ // silently swept a waived row whose plan was still open.
83
+ //
84
+ // NOT FRONTMATTER EITHER, and that follows from the harness's own doctrine rather than taste:
85
+ // `type:`/`owns:`/`applies:` and their siblings are tooling metadata, "preserved on edits, never
86
+ // narrated" — a key that by definition states nothing about behavior cannot answer a claim that
87
+ // documented behavior may have moved. So the bodies are compared with the frontmatter stripped.
88
+ //
89
+ // Unproven ⇒ the row stands. Every git failure returns false, per this file's rule that silence
90
+ // is its only acceptable failure mode.
77
91
  "doc-implicated": (projectDir, body) => {
78
92
  const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
79
93
  if (!m) return false;
80
94
  const [, src, doc] = m;
81
- const mtime = (rel) => {
82
- try {
83
- return fs.statSync(path.join(projectDir, rel)).mtimeMs;
84
- } catch (e) {
85
- return null;
86
- }
95
+ if (!fs.existsSync(path.join(projectDir, doc))) return false; // the doc is gone — a real finding
96
+ if (!fs.existsSync(path.join(projectDir, src))) return true; // the source is gone; nothing implicates it
97
+
98
+ const git = (...args) => spawnSync("git", args, { cwd: projectDir, timeout: 5000, encoding: "utf8" });
99
+ const lastCommit = git("log", "-1", "--format=%H", "--", src);
100
+ if (lastCommit.status !== 0) return false;
101
+ const since = lastCommit.stdout.trim();
102
+ if (!since) return false; // the source has never been committed — nothing to measure against
103
+
104
+ // Strip the leading `---` block so a metadata-only edit reads as no change at all.
105
+ const prose = (text) => (text.startsWith("---") ? text.slice(text.indexOf("\n---", 3) + 4) : text);
106
+ const at = (rev) => {
107
+ const res = git("show", `${rev}:${doc}`);
108
+ return res.status === 0 ? prose(res.stdout) : null;
87
109
  };
88
- const docTime = mtime(doc);
89
- const srcTime = mtime(src);
90
- if (docTime === null) return false; // the doc is gone — a real finding, not a resolved one
91
- if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
92
- return docTime >= srcTime;
110
+ const then = at(since);
111
+ if (then === null) return true; // the doc did not exist at that commit — creating it IS the update
112
+ const now = at("HEAD");
113
+ if (now === null) return false;
114
+ return now !== then;
93
115
  },
94
116
 
95
117
  // "<rel> has N doc-lint finding(s)" — dead once a re-run comes back clean (or the doc is gone).
@@ -105,7 +127,7 @@ export const ROW_VERIFIERS = {
105
127
  const m = /·\s+index-row-missing\s+·\s+(\S+)\s+is not linked/.exec(body);
106
128
  if (!m) return false;
107
129
  if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
108
- return readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(m[1]));
130
+ return readSafe(path.join(projectDir, docsRoot(projectDir), "README.md")).includes(path.basename(m[1]));
109
131
  },
110
132
 
111
133
  // "<rel> declares `<name>` (package-rejected)" — dead once the manifest drops the package
@@ -30,9 +30,10 @@ Rules for the plan regime between the plan gate and the verification gate:
30
30
  **A discrete, user-triggered step.** When implementation is complete, report it and offer the gate — never run it unbidden, and never flip `Status: Complete` without it. An explicit user acceptance of finished plan work — "ship it", "commit", "looks good" — counts as the trigger; open the gate without asking again. A plan with outstanding obligations stays open, whatever the code does. The procedure — checklist, drive-then-pin ladder, row discharge, what earns a durable test — is the `testing` skill (gate mode).
31
31
 
32
32
  - **Precondition: the ledger exists.** A plan with no Test Obligations section is blocked at the gate — run the `testing` skill (plan-gate mode) on it first; an empty ledger passes vacuously and the gap surfaces only after the file is frozen.
33
- - **The verdict is never delegated** (the root policy file § Delegation), and where browser work is main-session-only (its § Runtime Notes), the driving is too. Fan-out may prepare fixtures or draft test bodies; the driving and the verdict stay inline.
33
+ - **The verdict is never delegated** (the root policy file § Delegation), and where browser work is main-session-only (its § Runtime Notes), the driving is too. **Authoring test files is dispatched fan-out by default** — at any nesting level, sub-agents on the cheapest capable tier write the pins from the checklist and ledger rows while the gating agent keeps the checklist, the driving, the cold-shell suite run, and every discharge: a sub-agent's green claim is a report, and the verdict is the re-run. Only a lone regression test riding a bug fix stays inline — briefing a sub-agent costs more than the test.
34
34
  - **The gate needs no stronger tier than the session default** — a standard-tier session suffices whatever tier built the plan; the plan and its ledger carry all the state the gate needs, so gating in a fresh session loses nothing.
35
- - **Browser driving is the gate's final rung.** Playwright / Chrome CDP drives run once, at the end, after suites and cheap oracles are green — never interleaved mid-loop; they are the most expensive oracle in time and tokens.
35
+ - **On a rendered surface, browser driving LEADS the gate — it is not its final rung.** The order inverted deliberately: the defects that reach users through an interface are geometric and compositional, and a simulated DOM computes no layout, so a suite that is entirely green says nothing about whether anything is where it should be. **Reach the browser Chrome-CDP-first and Playwright second** — CDP drives a real browser interactively and needs no script written in advance, which is what discovery requires; Playwright is the fallback where CDP is unavailable and the tool of record for whatever must run unattended afterwards. Cheap oracles run in the same pass and are what the drive's findings get pinned into; they are no longer a gate to pass before looking. A surface with nothing rendered — a CLI, a service, a library — drives its own real binary or wire instead, and this clause does not apply.
36
+ - **A judged drive discovers; a scripted assertion guards.** Only the first finds a defect nobody has seen — a script asserting a control's value passes over a control drawn on top of its own label. Scripts are written after a judged drive names the class of defect, never generated ahead of one, and **a red script is never regenerated** — regeneration re-derives the assertion from the surface as it currently is, which turns a defect into the asserted truth with everything green.
36
37
  - **Report what happened, including failures.** A gate that reports green while a suite is red is worse than no gate; state the failing test and its output.
37
38
  - **Abandonment does not erase debt.** Before `Status: Abandoned` freezes a plan whose code was committed, convert its undischarged rows into backlog items; uncommitted code owes nothing.
38
39
 
@@ -40,13 +41,15 @@ Rules for the plan regime between the plan gate and the verification gate:
40
41
 
41
42
  Framework choice is **never** ad hoc and never the user's problem mid-task: detect the stack, use its row; if the repo already has a runner, that runner wins — **never** introduce a second framework beside it.
42
43
 
43
- | Stack | Unit / integration | Component | E2E | HTTP mocking | Don't use |
44
- | --- | --- | --- | --- | --- | --- |
45
- | Node + Vite / React (web) | `vitest` | `@testing-library/react` (+ `@testing-library/user-event`, `happy-dom`) | `@playwright/test` | `msw` | `jest`, `mocha`, `jasmine`, `cypress`, `enzyme` |
46
- | Express APIs | `vitest` | — | `supertest` against the app instance | `msw` (outbound calls) | `jest`, `chai-http` |
47
- | React Native / Expo | `jest` via the `jest-expo` preset | `@testing-library/react-native` | `maestro` | `msw` | `detox`, `enzyme` |
48
- | Cloudflare Workers | `vitest` + `@cloudflare/vitest-pool-workers` | — | `@playwright/test` against `wrangler dev` | `msw` | `jest` |
49
- | Rust | `cargo test` (built-in) | — | — | — | — |
44
+ | Stack | Unit / integration | Component | Rendered (7b) | Judged drive | E2E | HTTP mocking | Don't use |
45
+ | --- | --- | --- | --- | --- | --- | --- | --- |
46
+ | Node + Vite / React (web) | `vitest` | `@testing-library/react` (+ `@testing-library/user-event`, `happy-dom`) | `vitest` browser mode, Playwright provider | Chrome CDP, else `@playwright/test` headed | `@playwright/test` | `msw` | `jest`, `mocha`, `jasmine`, `cypress`, `enzyme` |
47
+ | Express APIs | `vitest` | — | — (nothing rendered) | the real wire — `curl`/HTTP against the running app | `supertest` against the app instance | `msw` (outbound calls) | `jest`, `chai-http` |
48
+ | React Native / Expo | `jest` via the `jest-expo` preset | `@testing-library/react-native` | `maestro` on a device/simulator | the simulator, watched | `maestro` | `msw` | `detox`, `enzyme` |
49
+ | Cloudflare Workers | `vitest` + `@cloudflare/vitest-pool-workers` | — | — (nothing rendered) | the real wire against `wrangler dev` | `@playwright/test` against `wrangler dev` | `msw` | `jest` |
50
+ | Rust | `cargo test` (built-in) | — | the app window, driven by the OS layer where one exists | the running binary, watched | — | — | — |
51
+
52
+ **The two new columns are the inversion made concrete.** *Rendered (7b)* is the unattended real-engine runner — where geometry pins live, and `—` where a stack renders nothing. *Judged drive* is what a person or an agent looks at when a change is finished, and it leads the gate on any stack that renders: Chrome CDP first because it drives a real browser with no script written in advance, Playwright headed where CDP is unavailable. A stack whose judged drive is `—` has no interface to look at, not permission to skip looking.
50
53
 
51
54
  Test packages install at `latest` — outside the approved-packages pin set. Fake data comes from `@ngneat/falso` — **never** `@faker-js/faker` / `chance`.
52
55
 
@@ -55,6 +58,7 @@ Test packages install at `latest` — outside the approved-packages pin set. Fak
55
58
  - **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).
56
59
  - **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.
57
60
  - **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.
61
+ - **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/`.
58
62
  - **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).
59
63
  - **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).
60
64