@nurix/etna 0.19.91 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/etna.js +0 -11
  2. package/data/catalog/rules/frontend.md +1 -1
  3. package/data/catalog/rules/general.md +3 -3
  4. package/data/catalog/rules/nomenclature.md +0 -4
  5. package/data/catalog/rules/pnpm.md +1 -1
  6. package/data/catalog/rules/testing.md +1 -0
  7. package/data/harness-index.json +1 -1
  8. package/data/harnesses/code/CLAUDE.md +14 -14
  9. package/data/harnesses/code/agents/bookkeeper.md +4 -1
  10. package/data/harnesses/code/agents/maintenance.md +3 -14
  11. package/data/harnesses/code/agents/performance-review.md +1 -1
  12. package/data/harnesses/code/codex/agents/performance-review.toml +1 -1
  13. package/data/harnesses/code/harness.json +2 -2
  14. package/data/harnesses/code/hooks/awareness.mjs +5 -169
  15. package/data/harnesses/code/hooks/compliance.mjs +38 -18
  16. package/data/harnesses/code/hooks/lib/ledger.mjs +36 -13
  17. package/data/harnesses/code/hooks/lib/state.mjs +85 -1
  18. package/data/harnesses/code/hooks/lib/sweep.mjs +172 -0
  19. package/data/harnesses/code/rules/frontend.md +1 -1
  20. package/data/harnesses/code/rules/general.md +3 -3
  21. package/data/harnesses/code/rules/nomenclature.md +0 -4
  22. package/data/harnesses/code/rules/pnpm.md +1 -1
  23. package/data/harnesses/code/rules/testing.md +1 -0
  24. package/data/harnesses/code/skills/boundary/SKILL.md +1 -1
  25. package/data/harnesses/code/skills/documentation/SKILL.md +1 -1
  26. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -1
  27. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +14 -8
  28. package/data/harnesses/code/skills/documentation/templates/plan.md +9 -16
  29. package/data/harnesses/code/skills/testing/SKILL.md +1 -2
  30. package/data/harnesses/delivery/agents/maintenance.md +3 -14
  31. package/data/harnesses/delivery/harness.json +2 -2
  32. package/data/harnesses/delivery/hooks/generate-ownership-index.mjs +3 -1
  33. package/data/harnesses/delivery/rules/nomenclature.md +0 -4
  34. package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -1
  35. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -1
  36. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +14 -8
  37. package/data/harnesses/delivery/skills/documentation/templates/plan.md +9 -16
  38. package/data/harnesses/etna/agents/maintenance.md +3 -14
  39. package/data/harnesses/etna/agents/performance-review.md +1 -1
  40. package/data/harnesses/etna/codex/agents/performance-review.toml +1 -1
  41. package/data/harnesses/etna/harness.json +2 -2
  42. package/data/harnesses/etna/hooks/generate-ownership-index.mjs +3 -1
  43. package/data/harnesses/etna/rules/general.md +3 -3
  44. package/data/harnesses/etna/rules/nomenclature.md +0 -4
  45. package/data/harnesses/etna/rules/testing.md +1 -0
  46. package/data/harnesses/etna/skills/boundary/SKILL.md +1 -1
  47. package/data/harnesses/etna/skills/documentation/SKILL.md +1 -1
  48. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -1
  49. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +14 -8
  50. package/data/harnesses/etna/skills/documentation/templates/plan.md +9 -16
  51. package/lib/codex/agents-md.mjs +1 -35
  52. package/lib/codex/hooks.mjs +0 -29
  53. package/lib/install/materialize.mjs +4 -34
  54. package/lib/install/plan.mjs +54 -10
  55. package/package.json +3 -3
  56. package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +0 -24
  57. package/data/harnesses/code/hooks/check-changelog-presence.mjs +0 -36
  58. package/data/harnesses/code/hooks/check-completion-integrity.mjs +0 -72
  59. package/data/harnesses/code/hooks/check-ownership.mjs +0 -55
  60. package/lib/codex/index.mjs +0 -63
  61. package/lib/codex/plugin-manifest.mjs +0 -42
  62. package/lib/codex/subagents.mjs +0 -26
@@ -11,7 +11,8 @@ import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
11
11
  import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT } from "./lib/repo.mjs";
12
12
  import { openGatedPlans, inPlanScope } from "./lib/plans.mjs";
13
13
  import { stagedExempt } from "./lib/exempt.mjs";
14
- import { readQueue, countRows, askedThisSession, recordAsked } from "./lib/state.mjs";
14
+ import { readQueue, countRows, rowsBlockingCommit, askedThisSession, recordAsked } from "./lib/state.mjs";
15
+ import { liveRows } from "./lib/sweep.mjs";
15
16
  import { frozenStatus } from "./lib/frozen.mjs";
16
17
  import { undischargedRows } from "./lib/ledger.mjs";
17
18
  import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
@@ -150,7 +151,15 @@ function commitGate(projectDir, sessionId) {
150
151
  .split("\n")
151
152
  .map((s) => s.trim())
152
153
  .filter(Boolean);
153
- if (staged.length > 0 && !stagedExempt(staged)) {
154
+ // A rename-only staged set is on the changelog rule's exemption list; the gate can now honor it
155
+ // the way CI's caller already does. At least one line, and every line a rename — one edit joins
156
+ // the set and the wall returns.
157
+ const nameStatus = (git(projectDir, ["diff", "--cached", "--name-status", "-M"]) || "")
158
+ .split("\n")
159
+ .map((s) => s.trim())
160
+ .filter(Boolean);
161
+ const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
162
+ if (staged.length > 0 && !stagedExempt(staged) && !allRenames) {
154
163
  if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
155
164
  return deny(
156
165
  projectDir,
@@ -161,24 +170,31 @@ function commitGate(projectDir, sessionId) {
161
170
  );
162
171
  }
163
172
  }
164
- const counts = countRows(readQueue(projectDir));
165
- if (counts.unresolved > 0) {
173
+ // The bookkeeping wall is scoped to the staged set — the grammar and its fail-closed cases are
174
+ // `lib/state.mjs` § What a row implicates. Rows about files this commit does not touch are
175
+ // somebody else's, and holding a commit behind rows its session may not drain is a wait with no
176
+ // action that clears it. The read goes through `liveRows` first: a gate tests conditions at gate
177
+ // time, never a cached record of them — a row whose condition died since the last sweep must not
178
+ // wall anything. Pure by design; the detector's next sweep deletes what this filtered out.
179
+ const { blocking, unrelated } = rowsBlockingCommit(liveRows(projectDir, readQueue(projectDir)), staged);
180
+ if (blocking.length > 0) {
181
+ const shown = blocking.slice(0, 8);
182
+ const lines = shown.map(({ row, hits }) =>
183
+ hits.length > 0
184
+ ? ` - ${row.kind}: ${row.body}\n ↳ implicates staged ${hits.join(", ")}`
185
+ : ` - ${row.kind}: ${row.body}\n ↳ names no staged path — a row like this blocks every commit`
186
+ );
187
+ if (blocking.length > shown.length) lines.push(` - …and ${blocking.length - shown.length} more`);
166
188
  return deny(
167
189
  projectDir,
168
190
  sessionId,
169
191
  "bookkeeping",
170
192
  "git commit",
171
- `BLOCKED — bookkeeping. ${counts.unresolved} unresolved row(s) in docs/operations/bookkeeping.md. Dispatch the bookkeeper, resolve, or convert to \`waived:\` with a reason and expiry.`
172
- );
173
- }
174
-
175
- // This session's own open denials wall the commit too — a commit is the arc's end as much as a
176
- // stop is, and an undisclosed failure must not ride out inside one.
177
- const open = unresolvedDenials(projectDir, sessionId).filter((pair) => !pair.startsWith("changelog ·") && !pair.startsWith("bookkeeping ·"));
178
- if (open.length > 0) {
179
- return decision(
180
- "deny",
181
- `BLOCKED — session gate log. ${open.length} denial(s) this session have no resolved line (${open.join("; ")}). Append \`resolved\` to the session log — retried-ok happens automatically on a successful retry; otherwise \`waived → {ref}\` or \`disclosed → {changelog entry}\`. Never delete log lines.`
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." +
195
+ (unrelated > 0
196
+ ? ` (${unrelated} further unresolved row(s) implicate nothing staged here — another session's work, and not yours to drain.)`
197
+ : "")
182
198
  );
183
199
  }
184
200
 
@@ -423,9 +439,13 @@ function stopGate(projectDir, payload) {
423
439
  if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
424
440
 
425
441
  // The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
426
- // session's open work must not block this one's stop. The commit wall stays global.
427
- const mine = readQueue(projectDir).filter(
428
- (row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`)
442
+ // session's open work must not block this one's stop. End-of-session accountability is per
443
+ // session and stays that way; the commit wall scopes by staged path instead, and CI by nothing.
444
+ // Filtered through `liveRows` (sid first — fewer verifier runs): the wall blocks on conditions
445
+ // that hold NOW, not on rows the last sweep happened to leave written down.
446
+ const mine = liveRows(
447
+ projectDir,
448
+ readQueue(projectDir).filter((row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`))
429
449
  );
430
450
  const counts = countRows(mine);
431
451
  if (counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
@@ -1,31 +1,54 @@
1
- // harness/code/hooks/lib/ledger.mjs — the Test Obligations table, read the way the walls read it.
2
- // A row is discharged when its Outcome cell states one of the gate's three outcomes. The plan file
3
- // is the checked truth — a discharge held only in conversation memory does not exist, which is what
1
+ // harness/code/hooks/lib/ledger.mjs — the plan's two ledgers, read the way the walls read them.
2
+ // A row is discharged when its Outcome cell states one of the closed outcomes. The plan file is
3
+ // the checked truth — a discharge held only in conversation memory does not exist, which is what
4
4
  // forces the gate to re-read the ledger from disk. Tables without an Outcome column predate the
5
5
  // contract and are out of scope: the same legacy tolerance the Gates: line gets, so an upgrade
6
6
  // cannot brick a plan already Complete.
7
7
 
8
8
  const OUTCOME = /^(satisfied|owes nothing|blocked)\b/i;
9
+ const DOC_OUTCOME = /^(updated|owes nothing|blocked)\b/i;
9
10
 
10
- // Row numbers (the `#` column) of every ledger row whose Outcome cell is empty or off-vocabulary.
11
- export function undischargedRows(planText) {
12
- const start = (planText || "").search(/^##\s+Test Obligations\b/m);
13
- if (start === -1) return [];
11
+ // The table rows of one `## <heading>` section, split into trimmed cells, plus the Outcome column
12
+ // index (null when the table predates the column — legacy, out of contract).
13
+ function sectionTable(planText, heading) {
14
+ const start = (planText || "").search(new RegExp(`^##\\s+${heading}\\b`, "m"));
15
+ if (start === -1) return null;
14
16
  const body = [];
15
17
  for (const line of planText.slice(start).split("\n").slice(1)) {
16
- if (/^##\s/.test(line)) break; // next section
18
+ if (/^##\s/.test(line)) break;
17
19
  body.push(line);
18
20
  }
19
21
  const rows = body.filter((line) => /^\s*\|/.test(line));
20
- if (rows.length < 3) return []; // header + divider + at least one data row
22
+ if (rows.length < 3) return null; // header + divider + at least one data row
21
23
  const header = rows[0].split("|").map((cell) => cell.trim().toLowerCase());
22
24
  const col = header.indexOf("outcome");
23
- if (col === -1) return []; // legacy table — predates the Outcome contract
25
+ return { rows: rows.slice(2).map((line) => line.split("|").map((cell) => cell.trim())), col: col === -1 ? null : col };
26
+ }
27
+
28
+ // Row numbers (the `#` column) of every ledger row whose Outcome cell is empty or off-vocabulary.
29
+ export function undischargedRows(planText) {
30
+ const table = sectionTable(planText, "Test Obligations");
31
+ if (!table || table.col === null) return [];
24
32
  const out = [];
25
- for (const line of rows.slice(2)) {
26
- const cells = line.split("|").map((cell) => cell.trim());
33
+ for (const cells of table.rows) {
27
34
  if (!/^\d+$/.test(cells[1] || "")) continue;
28
- if (!OUTCOME.test(cells[col] || "")) out.push(cells[1]);
35
+ if (!OUTCOME.test(cells[table.col] || "")) out.push(cells[1]);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Doc names of every Doc Obligations row whose Outcome cell is empty or off-vocabulary. Cells may
41
+ // be bolded (**updated**); a table without the column is legacy and passes — same tolerance as the
42
+ // test ledger, so the column's arrival cannot brick a plan already Complete.
43
+ export function undischargedDocRows(planText) {
44
+ const table = sectionTable(planText, "Doc Obligations");
45
+ if (!table || table.col === null) return [];
46
+ const out = [];
47
+ for (const cells of table.rows) {
48
+ const doc = (cells[1] || "").replace(/`/g, "");
49
+ if (!doc || /^-+$/.test(doc)) continue;
50
+ const outcome = (cells[table.col] || "").replace(/\*/g, "").trim();
51
+ if (!DOC_OUTCOME.test(outcome)) out.push(doc);
29
52
  }
30
53
  return out;
31
54
  }
@@ -9,15 +9,20 @@
9
9
  // only while its expires= date is in the future; past it, the deferral is over and the wall returns.
10
10
  // Rows are also re-verified against the repo before the walls read them (awareness.mjs's sweep),
11
11
  // so a row whose condition died is deleted rather than waiting for its date.
12
+ //
13
+ // The three walls read the queue at three widths: the commit wall takes only the rows implicating
14
+ // the commit's staged set (§ What a row implicates), the Stop wall takes the ending session's own
15
+ // rows, and CI takes every row. Accountability is per session; a commit is per set of files.
12
16
 
13
17
  import fs from "node:fs";
14
18
  import path from "node:path";
19
+ import { globToRegExp } from "./ownership.mjs";
15
20
 
16
21
  export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
17
22
 
18
23
  export const QUEUE_HEADER = `# Bookkeeping
19
24
 
20
- The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is either **resolved, which deletes it**, or converted to \`waived:\`. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the \`documentation\` skill's \`templates/bookkeeping.md\`.\n\n**Every row is a cache of a condition, not a record of an event.** A session-start sweep re-tests each row against the repo and deletes the ones whose condition has died — \`waived:\` rows included, because a waiver defers a finding and never makes one immortal. What the sweep cannot mechanically re-test ages out instead: a \`waived:\` row carries \`expires=\`, is PR-reviewable history until that date, and counts as **unresolved** past it — which returns the wall and re-summons the bookkeeper to re-judge whether the reason still holds.
25
+ The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit goes through only when every row **implicating one of its staged files** is either **resolved, which deletes it**, or converted to \`waived:\`. A row about files the commit does not touch belongs to whoever raised it; a row naming no file, and a commit staging none, block everything. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the \`documentation\` skill's \`templates/bookkeeping.md\`.\n\n**Every row is a cache of a condition, not a record of an event.** A session-start sweep re-tests each row against the repo and deletes the ones whose condition has died — \`waived:\` rows included, because a waiver defers a finding and never makes one immortal. What the sweep cannot mechanically re-test ages out instead: a \`waived:\` row carries \`expires=\`, is PR-reviewable history until that date, and counts as **unresolved** past it — which returns the wall and re-summons the bookkeeper to re-judge whether the reason still holds.
21
26
  `;
22
27
 
23
28
  export function queueFile(projectDir) {
@@ -143,6 +148,85 @@ export function countRows(rows, today = new Date().toISOString().slice(0, 10)) {
143
148
  return c;
144
149
  }
145
150
 
151
+ // The unresolved rows themselves — the same set countRows() tallies, as rows rather than a number.
152
+ export function unresolvedRows(rows, today = new Date().toISOString().slice(0, 10)) {
153
+ return rows.filter((row) => {
154
+ if (row.kind !== "waived") return true;
155
+ const expires = waiverExpiry(row);
156
+ return !expires || expires < today;
157
+ });
158
+ }
159
+
160
+ // --- What a row implicates -------------------------------------------------------------------
161
+ //
162
+ // THE COMMIT WALL IS SCOPED BY PATH. Every mechanical finding names the file it is about, so a
163
+ // commit can be walled by the rows implicating ITS staged set rather than by the whole queue.
164
+ // A global wall is unattributable the moment agents share a checkout: sibling agents also share a
165
+ // session id, so an agent ready to commit was held behind rows a concurrent sibling raised — rows
166
+ // the ownership etiquette (drain only what your own edits raised) forbids it from draining, which
167
+ // makes the wall a wait with no action that clears it. Measured on the fleet: a 15-minute stall
168
+ // behind a sibling's ten rows, and two agents in 5–6-minute mutual politeness loops.
169
+ //
170
+ // The shapes below mirror the ones awareness.mjs's ROW_VERIFIERS re-test — one grammar, two
171
+ // readers. Each rule captures every path the row names: the file the finding is about AND the doc
172
+ // that owns or records it, since staging either one is a commit answering for that row.
173
+ //
174
+ // A ROW THIS CANNOT PARSE IMPLICATES EVERYTHING. Scoping may narrow a wall on evidence, never on
175
+ // ignorance — so an unrecognized row, a judgment row, and a commit with nothing staged to compare
176
+ // against all keep the old repo-wide block.
177
+ const ROW_PATH_RULES = [
178
+ /·\s*doc-implicated\s*·\s*(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/,
179
+ /·\s*doc-lint\s*·\s*(\S+)\s+has\s/,
180
+ /·\s*header-missing\s*·\s*(\S+)\s+lacks\s/,
181
+ /·\s*index-row-missing\s*·\s*(\S+)\s+is not linked from\s+(\S+)/,
182
+ /·\s*package-rejected\s*·\s*(\S+)\s+declares\s/,
183
+ /·\s*frozen-record-edited\s*·\s*(\S+)\s+is a frozen record/,
184
+ /·\s*dead-glob\s*·\s*owns: glob\s+`([^`]+)`\s+in\s+(\S+)\s+matches/,
185
+ /→\s*lands-in:\s*(\S+\/\S+)/, // a note: row names the doc its context lands in
186
+ ];
187
+
188
+ // The paths a row implicates, or null when its grammar yields none.
189
+ export function rowPaths(row) {
190
+ for (const re of ROW_PATH_RULES) {
191
+ const m = re.exec(row.body);
192
+ if (!m) continue;
193
+ const paths = m.slice(1).filter(Boolean);
194
+ if (paths.length > 0) return paths;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // True when one of a row's tokens names a staged file. A token carrying `*` — the glob a dead-glob
200
+ // row quotes — compiles through the ownership index's engine; a plain token matches a staged file
201
+ // exactly, or as the directory it sits in.
202
+ function tokenHitsStaged(token, staged) {
203
+ if (token.includes("*")) {
204
+ const re = globToRegExp(token);
205
+ return staged.some((f) => re.test(f));
206
+ }
207
+ return staged.some((f) => f === token || f.startsWith(token + "/"));
208
+ }
209
+
210
+ // The unresolved rows a commit staging `staged` must answer for, each with the staged paths it
211
+ // implicates. `needs-session:` rows and expired waivers are judgments rather than mechanical
212
+ // findings — they carry no reliable path and stay global by kind, which is the semantics they had
213
+ // before scoping. Returns { blocking, unrelated }: `unrelated` counts the rows this commit is
214
+ // walked past, which the block message reports so scoping is visible rather than silent.
215
+ export function rowsBlockingCommit(rows, staged, today = new Date().toISOString().slice(0, 10)) {
216
+ const blocking = [];
217
+ let unrelated = 0;
218
+ for (const row of unresolvedRows(rows, today)) {
219
+ const paths = row.kind === "auto" || row.kind === "note" ? rowPaths(row) : null;
220
+ const hits = paths && staged.length > 0 ? paths.filter((p) => tokenHitsStaged(p, staged)) : null;
221
+ if (hits && hits.length === 0) {
222
+ unrelated += 1;
223
+ continue;
224
+ }
225
+ blocking.push({ row, hits: hits || [] });
226
+ }
227
+ return { blocking, unrelated };
228
+ }
229
+
146
230
  // Format one auto: row per the grammar; the sid tail is the only provenance kept —
147
231
  // a transcript path or timestamp is machine noise in a PR-reviewed queue.
148
232
  export function formatAutoRow({ date, kind, description, action, sessionId }) {
@@ -0,0 +1,172 @@
1
+ // harness/code/hooks/lib/sweep.mjs — row re-verification, shared by the detector and the walls.
2
+ //
3
+ // THE QUEUE IS DERIVED, NOT ACCUMULATED. Every finding here is recomputable from the repo, so a row
4
+ // is a cache of a condition rather than a record of an event — and a cache nothing invalidates is
5
+ // an archive. Two consumers, two shapes: `sweepResolvedRows` (the detector's writer — deletes dead
6
+ // rows from the queue file) and `liveRows` (the walls' pure read — same verifiers, NO file writes,
7
+ // because a PreToolUse gate or a CI check must never mutate the queue: on `git commit -a` a sweep's
8
+ // deletions would ride into the commit unauthored).
9
+ //
10
+ // THIS RUNS OVER `waived:` ROWS TOO, WHICH IS THE POINT. A waiver defers a finding; it does not
11
+ // make the finding immortal. Without this, waiving was strictly WORSE than leaving a row alone —
12
+ // the one existing resolver filtered `kind === "auto"`, so waiving a self-clearing row opted it out
13
+ // of its own return path forever. The condition, not the row's kind, decides.
14
+ //
15
+ // A kind with no verifier here is not swept — it ages out through `expires=` instead. Add a
16
+ // verifier whenever a finding becomes mechanically checkable; silence is the only failure mode
17
+ // this file has, so a verifier that is unsure must return false and leave the row standing.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { spawnSync } from "node:child_process";
22
+ import { globToRegExp, listRepoFiles } from "./ownership.mjs";
23
+ import { readSafe } from "./repo.mjs";
24
+ import { resolveRows } from "./state.mjs";
25
+ import { rejectedPackages } from "./approved.mjs";
26
+
27
+ // Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
28
+ export function docLint(projectDir, rel) {
29
+ const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
30
+ if (!fs.existsSync(script)) return [];
31
+ const res = spawnSync("node", [script, rel], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
32
+ const out = `${res.stdout || ""}`;
33
+ return out.split("\n").filter((line) => /:\d+ \[(block|warn)\]/.test(line));
34
+ }
35
+
36
+ // True when a source file's first lines carry its repo-relative path (the header-comment rule).
37
+ export function hasHeaderComment(projectDir, rel) {
38
+ const head = readSafe(path.join(projectDir, rel)).split("\n").slice(0, 5).join("\n");
39
+ return head.includes(rel) || head.includes(path.basename(rel));
40
+ }
41
+
42
+ // Verifier keys the detector no longer emits — swept when machine-raised, never taught to authors.
43
+ export const RETIRED_KINDS = ["doc-placement", "boundary-scan-suggested"];
44
+
45
+ export const ROW_VERIFIERS = {
46
+ // "<rel> lacks its file-path header comment" — dead once the header is there (or the file is gone).
47
+ "header-missing": (projectDir, body) => {
48
+ const m = /·\s+header-missing\s+·\s+(\S+)\s+lacks/.exec(body);
49
+ if (!m) return false;
50
+ const rel = m[1];
51
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
52
+ return hasHeaderComment(projectDir, rel);
53
+ },
54
+
55
+ // "owns: glob `<glob>` in <doc> matches no files" — dead once the glob matches something again.
56
+ "dead-glob": (projectDir, body, ctx) => {
57
+ const m = /`([^`]+)`/.exec(body);
58
+ if (!m) return false;
59
+ const re = globToRegExp(m[1]);
60
+ return ctx.repoFiles.some((f) => re.test(f));
61
+ },
62
+
63
+ // "<rel> is a frozen record and carries uncommitted modifications" — dead once it is committed.
64
+ // Immutability begins at commit, so the finding exists only while the edit is still in flight.
65
+ "frozen-record-edited": (projectDir, body) => {
66
+ const m = /·\s+frozen-record-edited\s+·\s+(\S+)\s+is a frozen record/.exec(body);
67
+ if (!m) return false;
68
+ const rel = m[1];
69
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
70
+ const res = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], { cwd: projectDir, timeout: 5000 });
71
+ // status 0 = no uncommitted modifications. A git failure leaves the row standing.
72
+ return res.status === 0;
73
+ },
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.
77
+ "doc-implicated": (projectDir, body) => {
78
+ const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
79
+ if (!m) return false;
80
+ 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
+ }
87
+ };
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;
93
+ },
94
+
95
+ // "<rel> has N doc-lint finding(s)" — dead once a re-run comes back clean (or the doc is gone).
96
+ "doc-lint": (projectDir, body) => {
97
+ const m = /·\s+doc-lint\s+·\s+(\S+)\s+has/.exec(body);
98
+ if (!m) return false;
99
+ if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
100
+ return docLint(projectDir, m[1]).length === 0;
101
+ },
102
+
103
+ // "<rel> is not linked from docs/README.md" — dead once the index names it (or it is gone).
104
+ "index-row-missing": (projectDir, body) => {
105
+ const m = /·\s+index-row-missing\s+·\s+(\S+)\s+is not linked/.exec(body);
106
+ if (!m) return false;
107
+ if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
108
+ return readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(m[1]));
109
+ },
110
+
111
+ // "<rel> declares `<name>` (package-rejected)" — dead once the manifest drops the package
112
+ // or the table stops rejecting it.
113
+ "package-rejected": (projectDir, body) => {
114
+ const m = /·\s+package-rejected\s+·\s+(\S+)\s+declares\s+`([^`]+)`/.exec(body);
115
+ if (!m) return false;
116
+ const [, rel, name] = m;
117
+ let manifest;
118
+ try {
119
+ manifest = JSON.parse(readSafe(path.join(projectDir, rel)));
120
+ } catch (e) {
121
+ return !fs.existsSync(path.join(projectDir, rel)); // gone ⇒ resolved; unparseable ⇒ standing
122
+ }
123
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
124
+ if (!(name in declared)) return true;
125
+ return !rejectedPackages(projectDir).has(name);
126
+ },
127
+
128
+ // Retired detector kinds — the harness no longer raises these, so a standing row caches a
129
+ // condition nothing can re-derive. Swept unconditionally — but only machine-raised rows: a
130
+ // note: or waived: row whose free text merely mentions the phrase is a human's, not ours.
131
+ "doc-placement": (projectDir, body, ctx, row) => row.kind === "auto",
132
+ "boundary-scan-suggested": (projectDir, body, ctx, row) => row.kind === "auto",
133
+ };
134
+
135
+ // The repo-file list is only needed by the dead-glob verifier — walk the tree lazily, once.
136
+ function verifierContext(projectDir, repoFiles) {
137
+ let cached = repoFiles || null;
138
+ return {
139
+ get repoFiles() {
140
+ if (!cached) cached = listRepoFiles(projectDir);
141
+ return cached;
142
+ },
143
+ };
144
+ }
145
+
146
+ // True when the row's condition no longer holds. Unsure (no verifier, verifier threw) ⇒ false.
147
+ function conditionDead(projectDir, row, ctx) {
148
+ if (row.kind === "needs-session") return false; // a human judgment, never swept mechanically
149
+ for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
150
+ if (!row.body.includes(kind)) continue;
151
+ try {
152
+ return verify(projectDir, row.body, ctx, row);
153
+ } catch (e) {
154
+ return false; // never drop a row because a verifier threw
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ // The walls' read: rows whose condition still holds, re-tested against the repo as it is NOW.
161
+ // Pure — the queue file is untouched; the detector's next sweep deletes what this filtered out.
162
+ export function liveRows(projectDir, rows, repoFiles) {
163
+ const ctx = verifierContext(projectDir, repoFiles);
164
+ return rows.filter((row) => !conditionDead(projectDir, row, ctx));
165
+ }
166
+
167
+ // The detector's writer: delete every row — `auto:` or `waived:` — whose condition no longer
168
+ // holds. Returns the count removed.
169
+ export function sweepResolvedRows(projectDir, repoFiles) {
170
+ const ctx = verifierContext(projectDir, repoFiles);
171
+ return resolveRows(projectDir, (row) => conditionDead(projectDir, row, ctx));
172
+ }
@@ -29,7 +29,7 @@ Every package compiles under `strict: true` and `noUncheckedIndexedAccess: true`
29
29
 
30
30
  - **Never** `any` to silence the checker. Unknown value → type `unknown`, then narrow (type guard / `zod` parse).
31
31
  - **Never** weaken strictness: no `// @ts-nocheck`; `// @ts-expect-error` only with a same-line reason.
32
- - Infer locals and return types; annotate at boundaries (exports, props, contracts).
32
+ - Annotate at boundaries (exports, props, contracts).
33
33
 
34
34
  ### Generics
35
35
 
@@ -19,11 +19,11 @@ How a package's `src/` is laid out. A single-runtime package drops `server/` and
19
19
 
20
20
  **Never `core/`** — its membership test collapses to "is it important?", making it the queue for files nobody could place; a bare `lib/` holding everything non-visual fails the same way, which is what `shared/` prevents. A route's own nested `lib/` is private and is **never** hoisted, and **dependencies point down** — a shared folder importing one route folder is a defect.
21
21
 
22
- **A folder earns its place at ~4+ files, or by being a boundary somebody could delete whole.** Below that, inline it: a folder around one file is a longer filename, and a folder holding only another folder is cascade with nothing at it. Density — not depth, not root breadth — is what makes a tree read as sprawling. The boundary clause splits the one-file cases: a one-file route folder stays, a one-file component folder goes.
22
+ **A folder earns its place at ~4+ files, or by being a boundary somebody could delete whole.** Below that, inline it. The boundary clause splits the one-file cases: a one-file route folder stays, a one-file component folder goes.
23
23
 
24
- **Never judge dead code by a name grep** — a relative import never contains its own parent's path, so `grep "x/brand"` cannot see `from "./brand/Detail"`, and every folder imported only by its parent reads as dead. Test by transitive reachability from the real entry points, resolving specifiers as the bundler does (`.ts`, `.tsx`, `.css`, `/index.ts`; strip `?raw`/`?url`; map a `.js` specifier onto its `.ts` source), running the test tree as a **second, separate** entry set — reachable from tests alone means a suite pinning code nothing ships, and both go together. Ambient `.d.ts` files enter through tsconfig `include`, so a sweep always reports them dead. A retired mechanism spans more than source — grep the build config for the same concept before calling a removal complete. **Unreachable ≠ unwanted**: a routed surface rendering dummy data is reachable and still not product, and retiring it removes a route, which is a product decision.
24
+ **Never judge dead code by a name grep** — a relative import never contains its own parent's path, so every folder imported only by its parent reads as dead. Test by transitive reachability from the real entry points, resolving specifiers as the bundler does (`.ts`, `.tsx`, `.css`, `/index.ts`; strip `?raw`/`?url`; map a `.js` specifier onto its `.ts` source), with the test tree as a **second, separate** entry set — reachable from tests alone is a suite pinning code nothing ships. Ambient `.d.ts` files enter through tsconfig `include`, so a sweep always reports them dead; grep the build config for a retired mechanism before calling its removal complete. **Unreachable ≠ unwanted**: retiring a routed dummy surface removes a route — a product decision.
25
25
 
26
- **Reshape an existing tree largest-lever-first**, verifying typecheck plus the full suite between steps: purge unreachable files → collapse single-occupancy and pass-through folders → push domain-coupled components down → name the shared layer (the only step that adds a top-level name) → optionally add a path alias, which makes depth invisible at the import site and dissolves most "flatten it" instincts. **Delete and promote before you move** — a folder whose name misdescribes its contents just relocates the lie.
26
+ **Reshape an existing tree largest-lever-first**, verifying typecheck plus the full suite between steps: purge unreachable files → collapse single-occupancy and pass-through folders → push domain-coupled components down → name the shared layer (the only step that adds a top-level name) → optionally add a path alias (depth becomes invisible at the import site). **Delete and promote before you move** — a folder whose name misdescribes its contents just relocates the lie.
27
27
 
28
28
  ## Runtime Versions
29
29
 
@@ -20,11 +20,7 @@ Names state intent in full words — clarity over brevity; no abbreviations unle
20
20
 
21
21
  | Identifier | Shape | Example |
22
22
  | --- | --- | --- |
23
- | Variable | noun phrase | `paymentStatus` |
24
- | Function / method | verb phrase naming the action | `validateInput` |
25
23
  | Boolean | `is` / `has` / `can` / `should` prefix — **never** a bare adjective | `hasPermission` |
26
24
  | Array / collection | plural noun | `errorMessages` |
27
- | Class / component | singular noun phrase | `RequestHandler` |
28
25
  | Event handler | `handle` or `on` prefix | `handleSubmit`, `onPageLoad` |
29
26
  | Interface | `I` prefix acceptable; type aliases stay plain | `IConfigOptions`, `AuthResponse` |
30
- | Async function | verb phrase; `Async` suffix acceptable | `loadConfigAsync` |
@@ -1,6 +1,6 @@
1
1
  # pnpm — `@nurix/*` Workspaces: naming, boundaries, and consuming
2
2
 
3
- How a `@nurix/*` repo is **named**, **structured**, and how its packages are **consumed** — every repo takes the workspace shape, single-package frontends included. Publishing and CLI design live in the `nustack` skill, not here.
3
+ How a `@nurix/*` repo is **named**, **structured**, and how its packages are **consumed** — every repo takes the workspace shape, single-package frontends included. Publishing and CLI design live in the `nustack` skill, not here — no `nustack` skill in the runtime ⇒ ask before crossing the publish boundary.
4
4
 
5
5
  ---
6
6
 
@@ -32,6 +32,7 @@ Rules for the plan regime between the plan gate and the verification gate:
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
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.
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
36
  - **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.
36
37
  - **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.
37
38
 
@@ -65,7 +65,7 @@ The packs are a floor, not a fence: report any contract that passes the ships-gr
65
65
 
66
66
  **BLOCK** = any CRITICAL. HIGH findings yield **PASS WITH AMENDMENTS** — fold the amendments into the plan before coding. Order amendments sequencing-first: many violations dissolve by reordering steps (expand-then-contract, migrate-before-code) rather than changing what ships — prefer that fix, drawn from the entry's safe-change sequence.
67
67
 
68
- 6. **Record the verdict in the plan's `Gates:` line.** Rewrite only your own segment, to exactly `Boundary: passed YYYY-MM-DD` (PASS, or PASS WITH AMENDMENTS once the amendments are folded) or `Boundary: blocked YYYY-MM-DD` (BLOCK). The compliance hook greps these exact forms — implementation edits stay blocked while the segment reads `pending`. A plan with no `Gates:` line predates the format; add the line rather than skipping the write-back.
68
+ 6. **Record the verdict in the plan's `Gates:` line.** Rewrite only your own segment, to exactly `Boundary: passed YYYY-MM-DD` (PASS, or PASS WITH AMENDMENTS once the amendments are folded) or `Boundary: blocked YYYY-MM-DD` (BLOCK). The compliance hook greps exactly this segment — implementation edits inside the plan's scope stay blocked while it reads `Boundary: pending` (the other gates are enforced by section presence, not segment text). A plan with no `Gates:` line predates the format; add the line rather than skipping the write-back.
69
69
 
70
70
  ## Rules
71
71
 
@@ -113,7 +113,7 @@ The two guarantees are complementary and both break on mixing: a reader holding
113
113
  | Plan | Record | `YYYY-MM-DD-kebab-case.md` | Freezes at `Complete` / `Abandoned` |
114
114
  | Changelog entry | Record | `YYYY-MM-DD-kebab-case.md` | Frozen; master index append-only |
115
115
  | Migration runbook | Record | `YYYY-MM-DD-kebab-case.md` | Freezes once executed, outcome stamped |
116
- | Research | Record | `history/research/{topic}/` | Frozen snapshot |
116
+ | Research | Record | in progress: `docs/research/{topic}/` · harvested: `docs/history/brainstorms/{topic}/` | Frozen at harvest |
117
117
  | Memory | Record | `{agent-name}.md` | Append-only dated ledger |
118
118
 
119
119
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  Truth document. Use for: **how to verify one feature's promised behavior by driving the real product** — the browser, the CLI binary, HTTP against the running service. One file per feature, named for the feature doc that owns the code (`docs/features/auth.md` → `docs/tests/auth.md`). No frontmatter; a checklist owns no code.
4
4
 
5
- **This is the driving script, and it is the source the suite is drawn from.** At the verification gate it does double duty: rung 1 drives the real surface *against these steps*, and rung 2 pins the durable subset *of these assertions* as suite tests. Writing it is therefore not a report written afterwards — it is authored or updated **before** the driving, so the gate has something to execute and the tests have something to be derived from. Which assertions earn a permanent test is decided by `verification.md` § What earns a durable test, and each section records the answer on its `Pinned:` line.
5
+ **This is the driving script, and it is the source the suite is drawn from.** At the verification gate it does double duty: rung 1 drives the real surface *against these steps*, and rung 2 pins the durable subset *of these assertions* as suite tests. Writing it is therefore not a report written afterwards — it is authored or updated **before** the driving, so the gate has something to execute and the tests have something to be derived from. Which assertions earn a permanent test is decided by the `testing` skill (gate mode) § What earns a suite test, and each section records the answer on its `Pinned:` line.
6
6
 
7
7
  A checklist outlives the tests it seeds. Where a Playwright script breaks on every selector change, a checklist states the behavior and lets the implementation move under it — so it is written in the product's own vocabulary (labels, URLs, rows) and never in the code's (function names, props, internal state).
8
8
 
@@ -1,18 +1,24 @@
1
1
  # Bookkeeping ledger (docs/operations/bookkeeping.md)
2
2
 
3
- Truth document with an unusual lifecycle: a **work queue**, not an archive. Rows are appended mechanically by the awareness hook (`auto:`) and deliberately by working sessions (`note:`), drained by the `bookkeeper` agent, and walled at Stop and commit — a commit is possible only when every row is resolved (deleted) or converted to `waived:`. What survives in git — `waived:` and `needs-session:` rows — is exactly what a PR reviewer should see.
3
+ Truth document with an unusual lifecycle: a **work queue**, not an archive. Rows are appended mechanically by the awareness hook (`auto:`) and deliberately by working sessions (`note:`), drained by the `bookkeeper` agent, and walled at Stop, at commit, and in CI. What survives in git — `waived:` and `needs-session:` rows — is exactly what a PR reviewer should see.
4
4
 
5
- The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `waived:` rows whose finding kind is `dead-glob` double as the dead-glob allowlist.
5
+ **Each wall reads the queue at its own width, and re-verifies conditions at gate time.** Stop takes the ending session's own rows, because accountability for an arc is per session. The commit gate takes only the rows **implicating that commit's staged files** — a row names the file its finding is about, and holding a commit behind rows raised by somebody else's edits is a wait nothing the committer may do can clear. CI takes every row on the branch, where there is no staged set to scope by. All three re-test each row against the repo before blocking, so a row whose condition has died walls nothing. Fail-closed cases stay repo-wide at the commit gate: a row whose grammar names no path, a `needs-session:` row, an expired waiver, and a commit staging nothing at all.
6
+
7
+ The fenced block's prose is a **verbatim quote of the queue header the hooks self-heal** (`QUEUE_HEADER` in the hooks' `lib/state.mjs`) — code is canonical; a wording change starts there. The row grammar lines beneath it are taught here and parsed by the hooks; keep both exact.
6
8
 
7
9
  ```
8
10
  # Bookkeeping
9
11
 
10
- The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is either **resolved, which deletes it**, or converted to `waived:`. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the `documentation` skill's `templates/bookkeeping.md`. A `waived:` row carries `expires=` and is PR-reviewable history.
12
+ The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit goes through only when every row **implicating one of its staged files** is either **resolved, which deletes it**, or converted to `waived:`. A row about files the commit does not touch belongs to whoever raised it; a row naming no file, and a commit staging none, block everything. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the `documentation` skill's `templates/bookkeeping.md`.
13
+
14
+ **Every row is a cache of a condition, not a record of an event.** A session-start sweep re-tests each row against the repo and deletes the ones whose condition has died — `waived:` rows included, because a waiver defers a finding and never makes one immortal. What the sweep cannot mechanically re-test ages out instead: a `waived:` row carries `expires=`, is PR-reviewable history until that date, and counts as **unresolved** past it — which returns the wall and re-summons the bookkeeper to re-judge whether the reason still holds.
11
15
 
12
- - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
13
- - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
14
- - needs-session: YYYY-MM-DD · {what the bookkeeper could not ground} → main session resolves | refs={original row ts}
15
- - waived: YYYY-MM-DD · {what is deferred} · reason: {why} → expires=YYYY-MM-DD
16
+ - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id}
17
+ - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid={session_id}
18
+ - needs-session: YYYY-MM-DD · {what the bookkeeper could not ground} → main session resolves | refs={implicated file}
19
+ - waived: YYYY-MM-DD · {finding-kind} · `{subject}` · reason: {why} → expires=YYYY-MM-DD
16
20
  ```
17
21
 
18
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`.
22
+ **A waiver covers a finding only when it names the finding's kind and its subject in backticks** — the matcher (`waiverCovers`) tests the backticked subject exactly, so a waiver written without them silences nothing and the wall stands. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `dead-glob` waivers double as the dead-glob allowlist; `ownership-overlap` waivers cover the CI ownership check the same way.
23
+
24
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`. The CI ownership check additionally accepts `waived:` rows of kind `ownership-overlap`.