@nurix/etna 0.20.2 → 0.20.4

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 (58) hide show
  1. package/data/catalog/rules/testing.md +20 -14
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/code/CLAUDE.md +15 -8
  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 +26 -18
  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 +20 -14
  22. package/data/harnesses/code/skills/boundary/SKILL.md +2 -2
  23. package/data/harnesses/code/skills/codegraph/SKILL.md +57 -0
  24. package/data/harnesses/code/skills/documentation/SKILL.md +12 -5
  25. package/data/harnesses/code/skills/documentation/references/frontmatter.md +29 -4
  26. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +18 -1
  27. package/data/harnesses/code/skills/documentation/templates/feature.md +1 -0
  28. package/data/harnesses/code/skills/documentation/templates/plan.md +3 -1
  29. package/data/harnesses/code/skills/documentation/templates/ux-behavior.md +1 -1
  30. package/data/harnesses/code/skills/issue-bridge/SKILL.md +55 -0
  31. package/data/harnesses/code/skills/issue-bridge/references/trackers/github.md +37 -0
  32. package/data/harnesses/code/skills/issue-bridge/references/trackers/jira.md +40 -0
  33. package/data/harnesses/code/skills/issue-bridge/references/trackers/linear.md +37 -0
  34. package/data/harnesses/code/skills/testing/SKILL.md +24 -8
  35. package/data/harnesses/delivery/agents/maintenance.md +36 -3
  36. package/data/harnesses/delivery/harness.json +4 -2
  37. package/data/harnesses/delivery/skills/documentation/SKILL.md +12 -5
  38. package/data/harnesses/delivery/skills/documentation/references/frontmatter.md +29 -4
  39. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +18 -1
  40. package/data/harnesses/delivery/skills/documentation/templates/feature.md +1 -0
  41. package/data/harnesses/delivery/skills/documentation/templates/plan.md +3 -1
  42. package/data/harnesses/delivery/skills/documentation/templates/ux-behavior.md +1 -1
  43. package/data/harnesses/discover/harness.json +2 -0
  44. package/data/harnesses/discussion/harness.json +2 -0
  45. package/data/harnesses/etna/agents/maintenance.md +36 -3
  46. package/data/harnesses/etna/harness.json +4 -2
  47. package/data/harnesses/etna/rules/testing.md +20 -14
  48. package/data/harnesses/etna/skills/boundary/SKILL.md +2 -2
  49. package/data/harnesses/etna/skills/documentation/SKILL.md +12 -5
  50. package/data/harnesses/etna/skills/documentation/references/frontmatter.md +29 -4
  51. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +18 -1
  52. package/data/harnesses/etna/skills/documentation/templates/feature.md +1 -0
  53. package/data/harnesses/etna/skills/documentation/templates/plan.md +3 -1
  54. package/data/harnesses/etna/skills/documentation/templates/ux-behavior.md +1 -1
  55. package/data/harnesses/hr/harness.json +2 -0
  56. package/data/harnesses/kb-agi/harness.json +2 -0
  57. package/lib/install/plan.mjs +22 -1
  58. 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,12 +460,12 @@ 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
  : "") +
460
- "If implementation of the open plan is complete, also report it and offer the verification gate — never run it unbidden.",
468
+ "If the open plan's code is written, its gate is the build's tail — drive, fix, pin, discharge before stopping; the freeze and the commit are the user's.",
461
469
  };
462
470
  }
463
471
 
@@ -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