@nurix/etna 0.19.90 → 0.19.92

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "8d6f9b80d64d",
2
+ "fingerprint": "a1cf8eb35f57",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -139,7 +139,7 @@ Defaults — ❌ prohibitions hold unless the user explicitly asks otherwise;
139
139
  - ❌ **Branches & Worktrees [override]** – Never self-create git branches or worktrees; commit straight to the checked-out branch. This replaces any built-in "branch first on the default branch" default. Exception: when the user asks for a pull request, create the branch the PR requires before committing its changes.
140
140
  - ❌ **Protected Files** – Once per session, if `docs/operations/protected-files.md` exists, read it and never modify any path it lists; never modify `AGENTS.md` (harness-regenerated) or `protected-files.md` itself.
141
141
  - ✅ **Browser Automation** – If the runtime provides browser automation, use it freely; no need to ask first. If it doesn't — or the executing agent can't hold the browser connection (see Runtime Notes) — return a browser-verification checklist (URLs, actions, expected outcomes) for the orchestrating session or the user to run.
142
- - ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved rows remain — dispatch the `bookkeeper` agent to drain the queue. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all. **Every row is a cache of a condition, not a record of an event** — the session-start sweep re-tests each row against the repo and deletes the ones whose condition has died, `waived:` rows included, so the file empties itself as the work lands. What the sweep cannot re-test ages out instead: **a `waived:` row is unresolved once `expires=` passes**, which returns the wall and re-summons the bookkeeper to re-judge the reason. A waiver defers a finding; it never makes one immortal.
142
+ - ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while any unresolved row **implicates a file the commit stages** — dispatch the `bookkeeper` agent to drain those rows. Rows about files the commit does not touch are whoever raised them's, and are not yours to drain; a row naming no path, a `needs-session:` row, an expired waiver, and a commit staging nothing block regardless. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all. **Every row is a cache of a condition, not a record of an event** — the session-start sweep re-tests each row against the repo and deletes the ones whose condition has died, `waived:` rows included, so the file empties itself as the work lands. What the sweep cannot re-test ages out instead: **a `waived:` row is unresolved once `expires=` passes**, which returns the wall and re-summons the bookkeeper to re-judge the reason. A waiver defers a finding; it never makes one immortal.
143
143
  - ✅ **Cleanup removes the cause, not the record of it.** When the user asks for something to be deleted or cleaned up, the ask is for the underlying condition to be gone. Deleting the record while the condition survives is worse than doing nothing — it destroys the only evidence the debt exists. The order is fixed: **read what the record is masking** (a `waived:` row, an `UNBUILT:` marker, a `status:` flag, a lint suppression — each asserts something is fine; check whether that is still true), **fix the condition**, **then delete the record**. Reversing it backfires concretely: a deleted waiver's finding is re-appended by the detector as an `auto:` row, which *fails* the commit wall the `waived:` row passed.
144
144
  - ✅ **Delegation** — when to fan out, tiers, and trust are § Delegation.
145
145
  - ⚠️ **Plans** – Plans are created directly in `docs/history/plans/` (see Workflows). Once `Status: Complete` or `Status: Abandoned`, the file is frozen — never edited. Abandoning a plan whose code was committed first converts its undischarged Test Obligations into backlog items (the `testing` rule). If scope changes after completion, write a new dated plan that links back to the old one.
@@ -7,7 +7,7 @@ model: sonnet
7
7
 
8
8
  # Bookkeeper Agent
9
9
 
10
- You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the Stop/commit walls hold until the queue is clean or waived.
10
+ You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the walls hold until the rows each one reads are clean or waived — Stop reads the ending session's rows, the commit gate reads the rows implicating that commit's staged files, CI reads them all.
11
11
 
12
12
  ## Source-of-truth order — the repo first, the transcript last
13
13
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.10.13",
4
+ "version": "3.10.15",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -15,5 +15,5 @@
15
15
  "docs/memory/main.md",
16
16
  ".mcp.json"
17
17
  ],
18
- "digest": "e58c5f75136f"
18
+ "digest": "f07032c97db1"
19
19
  }
@@ -26,11 +26,13 @@ import {
26
26
  formatNeedsSessionRow,
27
27
  appendRows,
28
28
  resolveRows,
29
- unexpiredWaivers,
29
+ waiverCovers,
30
30
  stateDir,
31
31
  recordTouched,
32
32
  } from "./lib/state.mjs";
33
- import { frozenStatus } from "./lib/frozen.mjs";
33
+ import { readSafe, relIfInside, today, SOURCE_EXT, commandRoot } from "./lib/repo.mjs";
34
+ import { listPlans } from "./lib/plans.mjs";
35
+ import { frozenStatusAtHead } from "./lib/frozen.mjs";
34
36
  import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
35
37
  import {
36
38
  recordSession,
@@ -42,24 +44,6 @@ import {
42
44
  } from "./lib/claims.mjs";
43
45
  import { appendEvent } from "./lib/session-log.mjs";
44
46
 
45
- const today = () => new Date().toISOString().slice(0, 10);
46
-
47
- function readSafe(file) {
48
- try {
49
- return fs.readFileSync(file, "utf8");
50
- } catch (e) {
51
- return "";
52
- }
53
- }
54
-
55
- function readJsonSafe(file) {
56
- try {
57
- return JSON.parse(fs.readFileSync(file, "utf8"));
58
- } catch (e) {
59
- return null;
60
- }
61
- }
62
-
63
47
  // ---------------------------------------------------------------------------
64
48
  // SessionStart
65
49
  // ---------------------------------------------------------------------------
@@ -79,7 +63,12 @@ function detectPackageManager(projectDir) {
79
63
  }
80
64
 
81
65
  function harnessLine(projectDir) {
82
- const marker = readJsonSafe(path.join(projectDir, ".claude", "harness.json"));
66
+ let marker = null;
67
+ try {
68
+ marker = JSON.parse(readSafe(path.join(projectDir, ".claude", "harness.json")));
69
+ } catch (e) {
70
+ /* absent or unparseable — no line */
71
+ }
83
72
  if (!marker || !marker.version) return null;
84
73
  const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
85
74
  const stamped = (/harness-version:\s*([\w.-]+)/.exec(frontmatter(structure) || structure) || [])[1];
@@ -104,26 +93,14 @@ function memoryLine(projectDir) {
104
93
  }
105
94
 
106
95
  function openPlanLines(projectDir) {
107
- const dir = path.join(projectDir, "docs", "history", "plans");
108
- let entries;
109
- try {
110
- entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
111
- } catch (e) {
112
- return [];
113
- }
114
- const lines = [];
115
- for (const file of entries) {
116
- const text = readSafe(path.join(dir, file));
117
- const status = (/^\*\*Status:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
118
- if (!status || /Complete|Abandoned/.test(status)) continue;
119
- const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
120
- const ledgerRows = (text.match(/^\|\s*\d+\s*\|/gm) || []).length;
121
- const parts = [`Open plan: ${file} (${status}`];
122
- if (gates) parts.push(` · Gates: ${gates}`);
123
- if (ledgerRows) parts.push(` · ledger: ${ledgerRows} rows`);
124
- lines.push(parts.join("") + ")");
125
- }
126
- return lines;
96
+ return listPlans(projectDir)
97
+ .filter((p) => !/Complete|Abandoned/.test(p.status))
98
+ .map((p) => {
99
+ const parts = [`Open plan: ${p.file} (${p.status}`];
100
+ if (p.gates) parts.push(` · Gates: ${p.gates}`);
101
+ if (p.ledgerRows) parts.push(` · ledger: ${p.ledgerRows} rows`);
102
+ return parts.join("") + ")";
103
+ });
127
104
  }
128
105
 
129
106
  // --- the re-verification sweep ---------------------------------------------------------------
@@ -190,6 +167,45 @@ const ROW_VERIFIERS = {
190
167
  if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
191
168
  return docTime >= srcTime;
192
169
  },
170
+
171
+ // "<rel> has N doc-lint finding(s)" — dead once a re-run comes back clean (or the doc is gone).
172
+ "doc-lint": (projectDir, body) => {
173
+ const m = /·\s+doc-lint\s+·\s+(\S+)\s+has/.exec(body);
174
+ if (!m) return false;
175
+ if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
176
+ return docLint(projectDir, m[1]).length === 0;
177
+ },
178
+
179
+ // "<rel> is not linked from docs/README.md" — dead once the index names it (or it is gone).
180
+ "index-row-missing": (projectDir, body) => {
181
+ const m = /·\s+index-row-missing\s+·\s+(\S+)\s+is not linked/.exec(body);
182
+ if (!m) return false;
183
+ if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
184
+ return readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(m[1]));
185
+ },
186
+
187
+ // "<rel> declares `<name>` (package-rejected)" — dead once the manifest drops the package
188
+ // or the table stops rejecting it.
189
+ "package-rejected": (projectDir, body) => {
190
+ const m = /·\s+package-rejected\s+·\s+(\S+)\s+declares\s+`([^`]+)`/.exec(body);
191
+ if (!m) return false;
192
+ const [, rel, name] = m;
193
+ let manifest;
194
+ try {
195
+ manifest = JSON.parse(readSafe(path.join(projectDir, rel)));
196
+ } catch (e) {
197
+ return !fs.existsSync(path.join(projectDir, rel)); // gone ⇒ resolved; unparseable ⇒ standing
198
+ }
199
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
200
+ if (!(name in declared)) return true;
201
+ return !rejectedPackages(projectDir).has(name);
202
+ },
203
+
204
+ // Retired detector kinds — the harness no longer raises these, so a standing row caches a
205
+ // condition nothing can re-derive. Swept unconditionally — but only machine-raised rows: a
206
+ // note: or waived: row whose free text merely mentions the phrase is a human's, not ours.
207
+ "doc-placement": (projectDir, body, ctx, row) => row.kind === "auto",
208
+ "boundary-scan-suggested": (projectDir, body, ctx, row) => row.kind === "auto",
193
209
  };
194
210
 
195
211
  // Delete every row — `auto:` or `waived:` — whose condition no longer holds. Returns the count.
@@ -200,7 +216,7 @@ export function sweepResolvedRows(projectDir, repoFiles) {
200
216
  for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
201
217
  if (!row.body.includes(kind)) continue;
202
218
  try {
203
- return verify(projectDir, row.body, ctx);
219
+ return verify(projectDir, row.body, ctx, row);
204
220
  } catch (e) {
205
221
  return false; // never delete a row because a verifier threw
206
222
  }
@@ -215,56 +231,40 @@ function driftBanner(projectDir) {
215
231
  // Re-verify before counting: the walls must act on conditions that are live NOW, not on rows
216
232
  // that happen to still be written down.
217
233
  const swept = sweepResolvedRows(projectDir, repoFiles);
218
- const dead = deadGlobs(projectDir, repoFiles);
234
+ const docs = scanFeatureDocs(projectDir);
235
+ const dead = deadGlobs(projectDir, repoFiles, docs);
219
236
  const queueRows = readQueue(projectDir);
220
237
  const counts = countRows(queueRows);
221
- const waivers = unexpiredWaivers(queueRows, "dead-glob", today());
222
- const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
223
-
224
- // A waiver becomes a wall the day it expires, so the queue says so a week ahead — the deferral
225
- // ending should be a date somebody saw coming, not a commit that suddenly blocks.
226
- const soon = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
227
- const expiringSoon = queueRows
228
- .filter((r) => r.kind === "waived")
229
- .map((r) => {
230
- const d = (/expires=(\d{4}-\d{2}-\d{2})/.exec(r.body) || [])[1];
231
- if (!d || d < today() || d > soon) return null;
232
- // Drop the row's own raised-date prefix and its reason tail — the expiry and the finding are
233
- // what a reader is scanning for here.
234
- const finding = r.body.replace(/^\d{4}-\d{2}-\d{2}\s+·\s+/, "").split(" · reason:")[0];
235
- return `expires ${d} · ${finding.slice(0, 120)}`;
236
- })
237
- .filter(Boolean)
238
- .sort();
238
+ const unwaivedDead = dead.filter((d) => !waiverCovers(queueRows, "dead-glob", d.glob, today()));
239
239
 
240
240
  // A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
241
241
  // so waiving a finding class actually empties the rows it supersedes.
242
242
  resolveRows(projectDir, (row) => {
243
243
  if (row.kind !== "auto" || !row.body.includes("dead-glob")) return false;
244
244
  const m = /`([^`]+)`/.exec(row.body);
245
- return !!m && waivers.some((w) => w.body.includes(`\`${m[1]}\``));
245
+ return !!m && waiverCovers(queueRows, "dead-glob", m[1], today());
246
246
  });
247
247
 
248
- // Docs older than the code they own (doc mtime < newest owned-file mtime).
249
- const staleDocs = [];
250
- for (const entry of scanFeatureDocs(projectDir)) {
251
- let docTime;
252
- try {
253
- docTime = fs.statSync(path.join(projectDir, entry.doc)).mtimeMs;
254
- } catch (e) {
255
- continue;
256
- }
257
- const res = entry.owns.map((g) => globToRegExp(g));
258
- let newest = 0;
259
- for (const file of repoFiles) {
260
- if (!res.some((re) => re.test(file))) continue;
248
+ // Docs older than the code they own (doc mtime < newest owned-file mtime) — one stat per
249
+ // repo file, cached across docs, instead of docs × files stats.
250
+ const mtimes = new Map();
251
+ const mtimeOf = (rel) => {
252
+ if (!mtimes.has(rel)) {
261
253
  try {
262
- newest = Math.max(newest, fs.statSync(path.join(projectDir, file)).mtimeMs);
254
+ mtimes.set(rel, fs.statSync(path.join(projectDir, rel)).mtimeMs);
263
255
  } catch (e) {
264
- /* deleted between walk and stat */
256
+ mtimes.set(rel, null);
265
257
  }
266
258
  }
267
- if (newest > docTime) staleDocs.push(entry.doc);
259
+ return mtimes.get(rel);
260
+ };
261
+ const staleDocs = [];
262
+ for (const entry of docs) {
263
+ const docTime = mtimeOf(entry.doc);
264
+ if (docTime === null) continue;
265
+ const res = entry.owns.map((g) => globToRegExp(g));
266
+ const stale = repoFiles.some((file) => res.some((re) => re.test(file)) && (mtimeOf(file) ?? 0) > docTime);
267
+ if (stale) staleDocs.push(entry.doc);
268
268
  }
269
269
 
270
270
  const report = [
@@ -277,9 +277,6 @@ function driftBanner(projectDir) {
277
277
  ...staleDocs.map((d) => `- ${d}`),
278
278
  "",
279
279
  `## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.expiredWaived} expired-waived; ${counts.waived} waived total, ${swept} swept as resolved this session)`,
280
- ...(expiringSoon.length
281
- ? ["", `## Waivers expiring within 7 days (${expiringSoon.length})`, ...expiringSoon.map((w) => `- ${w}`)]
282
- : []),
283
280
  ].join("\n");
284
281
  try {
285
282
  fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
@@ -301,16 +298,10 @@ function driftBanner(projectDir) {
301
298
 
302
299
  function sessionStart(projectDir, payload = {}) {
303
300
  // Stamp the session's start so gitless repos still have a "modified since" horizon for the
304
- // outcome detector's fallback scan (dirtyPaths). The legacy shared stamp stays for its reader;
305
- // the per-sid twin exists because two concurrent sessions clobber the shared one. Best-effort.
301
+ // outcome detector's fallback scan (dirtyPaths). Best-effort.
306
302
  const sid = payload.session_id;
307
- const stamp = new Date().toISOString() + "\n";
308
303
  try {
309
- fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), stamp);
310
- if (sid) {
311
- const short = String(sid).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8);
312
- fs.writeFileSync(path.join(stateDir(projectDir), `session-started-${short}`), stamp);
313
- }
304
+ fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), new Date().toISOString() + "\n");
314
305
  } catch (e) {
315
306
  /* the stamp is a convenience for the fallback; git repos never need it */
316
307
  }
@@ -350,6 +341,14 @@ function sessionStart(projectDir, payload = {}) {
350
341
  const memory = memoryLine(projectDir);
351
342
  if (memory) lines.push(memory);
352
343
  lines.push(...openPlanLines(projectDir).slice(0, 4));
344
+ // A gate that crashed or skipped a write left a trace here — surface the count so the
345
+ // failure log finally has its reader.
346
+ try {
347
+ const failures = readSafe(path.join(stateDir(projectDir), "hook-failures.log")).split("\n").filter(Boolean);
348
+ if (failures.length) lines.push(`Hook failures logged: ${failures.length} (.claude/state/hook-failures.log) — enforcement may have gaps.`);
349
+ } catch (e) {
350
+ /* the count is a convenience */
351
+ }
353
352
  try {
354
353
  const drift = driftBanner(projectDir);
355
354
  if (drift) lines.push(drift);
@@ -373,15 +372,6 @@ function sessionStart(projectDir, payload = {}) {
373
372
  // PostToolUse — the silent detector
374
373
  // ---------------------------------------------------------------------------
375
374
 
376
- const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
377
-
378
- function relIfInside(projectDir, filePath) {
379
- if (!filePath) return null;
380
- const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
381
- if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
382
- return rel.split(path.sep).join("/");
383
- }
384
-
385
375
  // Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
386
376
  function docLint(projectDir, rel) {
387
377
  const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
@@ -441,20 +431,6 @@ function detectorForFile(projectDir, rel, meta, rows) {
441
431
  );
442
432
  }
443
433
  }
444
- const segment = rel.split("/")[1];
445
- if (rel.startsWith("docs/") && segment && !segment.endsWith(".md")) {
446
- const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
447
- if (structure && !structure.includes(`${segment}/`) && !structure.includes(`docs/${segment}`)) {
448
- rows.push(
449
- formatAutoRow({
450
- ...meta,
451
- kind: "doc-placement",
452
- description: `${rel} sits in a folder absent from docs/structure.md's authority map`,
453
- action: "move the doc per the add-a-doc table, or record the new folder in structure.md",
454
- })
455
- );
456
- }
457
- }
458
434
  if (rel.startsWith("docs/features/") && !readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(rel))) {
459
435
  rows.push(
460
436
  formatAutoRow({
@@ -489,24 +465,14 @@ function detectorForFile(projectDir, rel, meta, rows) {
489
465
  })
490
466
  );
491
467
  }
492
- if (/(schema|init\.sql|migration|auth|webhook|\benv\b|wrangler)/i.test(rel)) {
493
- rows.push(
494
- formatAutoRow({
495
- ...meta,
496
- kind: "boundary-scan-suggested",
497
- description: `${rel} is contract-heavy`,
498
- action: "run the boundary skill scoped to the diff",
499
- })
500
- );
501
- }
502
468
  }
503
469
  }
504
470
 
505
471
  function detectRenames(projectDir, command, meta, rows) {
506
472
  if (!/\bgit mv\b|\bmv\b/.test(command || "")) return;
507
- const waivers = unexpiredWaivers(readQueue(projectDir), "dead-glob", today());
473
+ const queueRows = readQueue(projectDir);
508
474
  for (const { glob, doc } of deadGlobs(projectDir)) {
509
- if (waivers.some((w) => w.body.includes(`\`${glob}\``))) continue;
475
+ if (waiverCovers(queueRows, "dead-glob", glob, today())) continue;
510
476
  rows.push(
511
477
  formatAutoRow({
512
478
  ...meta,
@@ -612,7 +578,9 @@ function detectOutcomes(projectDir, meta, rows) {
612
578
  }
613
579
  }
614
580
  if (rel.startsWith("docs/history/")) {
615
- const frozen = frozenStatus(projectDir, rel);
581
+ // Frozen AT HEAD, not in the worktree: the completion flip makes a plan dirty+Complete in
582
+ // one edit, and the worktree predicate walled the very commit that ceremony requires.
583
+ const frozen = frozenStatusAtHead(projectDir, rel);
616
584
  if (frozen.frozen && !raised(`${rel} is a frozen record`)) {
617
585
  rows.push(
618
586
  formatAutoRow({
@@ -638,11 +606,10 @@ function postToolUse(projectDir, payload) {
638
606
  const rows = [];
639
607
  if (payload.tool_name === "Bash") {
640
608
  // A completed `git commit` releases the claim compliance took at PreToolUse — the `end` line
641
- // is what lets a waiting session verify the actor finished. Root resolution mirrors the
642
- // gate's: an explicit `cd` in the command wins, so cross-repo commits release where they claimed.
643
- if (/(?:^|&&|;|\|)\s*(?:sudo\s+)?git\s+commit\b/.test(input.command || "")) {
644
- const cdMatch = /(?:^|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(input.command || "");
645
- const root = cdMatch && path.isAbsolute(cdMatch[1]) && fs.existsSync(cdMatch[1]) ? cdMatch[1] : projectDir;
609
+ // is what lets a waiting session verify the actor finished. Root resolution is the gate's own
610
+ // `commandRoot`, shared, so a claim and its release can never resolve to different repos.
611
+ if (/(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?git\s+commit\b/.test(input.command || "")) {
612
+ const root = commandRoot(projectDir, payload, input.command);
646
613
  try {
647
614
  releaseActivity(root, "commit", payload.session_id);
648
615
  appendEvent(root, payload.session_id, "coord", "commit-claim", "released");
@@ -1,6 +1,10 @@
1
1
  // harness/code/hooks/check-bookkeeping-clean.mjs — CI backstop: no unresolved bookkeeping rows
2
2
  // (auto:/note:/needs-session:) may reach the remote. waived: rows pass — they are the recorded,
3
3
  // PR-reviewable exceptions. Exit 1 when unresolved rows exist.
4
+ //
5
+ // THIS CHECK IS GLOBAL AND STAYS GLOBAL. The commit gate scopes to one commit's staged set so
6
+ // concurrent agents in one checkout do not wall each other; CI judges the branch as a whole, where
7
+ // there is no staged set to scope by and every row has had every commit to be drained in.
4
8
 
5
9
  import { readQueue, countRows } from "./lib/state.mjs";
6
10
 
@@ -3,15 +3,11 @@
3
3
  // judged mechanically: lockfile-only, harness-file-only, pure renames, changelog housekeeping,
4
4
  // or a bot author. Exit 1 when a non-exempt HEAD stages no entry.
5
5
 
6
- import { spawnSync } from "node:child_process";
7
- import path from "node:path";
6
+ import { changelogExemptFile } from "./lib/exempt.mjs";
7
+ import { git as gitAt } from "./lib/repo.mjs";
8
8
 
9
9
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
-
11
- function git(args) {
12
- const res = spawnSync("git", args, { cwd: projectDir, encoding: "utf8" });
13
- return res.status === 0 ? (res.stdout || "").trim() : null;
14
- }
10
+ const git = (args) => gitAt(projectDir, args);
15
11
 
16
12
  const nameStatus = git(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "HEAD"]) || "";
17
13
  const entries = nameStatus
@@ -22,28 +18,11 @@ const entries = nameStatus
22
18
  return { status, file: files[files.length - 1] };
23
19
  });
24
20
 
25
- const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
26
- const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
27
- const HARNESS_FILES = [
28
- "AGENTS.md",
29
- "CLAUDE.md",
30
- ".mcp.json",
31
- ".vscode/settings.json",
32
- ".prettierignore",
33
- ".prettierrc.json",
34
- ];
35
-
36
- const exemptFile = (f) =>
37
- LOCKFILES.test(path.basename(f)) ||
38
- HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
39
- HARNESS_FILES.includes(f) ||
40
- f.startsWith("docs/history/changelog/") ||
41
- f === "docs/history/CHANGELOG.md";
42
21
 
43
22
  const author = git(["log", "-1", "--format=%an <%ae>"]) || "";
44
23
  const isBot = /\[bot\]|actions@github\.com/i.test(author);
45
24
  const allRenames = entries.length > 0 && entries.every((e) => e.status.startsWith("R"));
46
- const allExempt = entries.length === 0 || entries.every((e) => exemptFile(e.file));
25
+ const allExempt = entries.length === 0 || entries.every((e) => changelogExemptFile(e.file));
47
26
  const hasEntry = entries.some((e) => e.file.startsWith("docs/history/changelog/") && e.file.endsWith(".md"));
48
27
 
49
28
  if (isBot || allRenames || allExempt || hasEntry) {
@@ -8,18 +8,11 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { frontmatter, field } from "./lib/ownership.mjs";
10
10
  import { undischargedRows } from "./lib/ledger.mjs";
11
+ import { readSafe } from "./lib/repo.mjs";
11
12
 
12
13
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
13
14
  const plansDir = path.join(projectDir, "docs", "history", "plans");
14
15
 
15
- function readSafe(file) {
16
- try {
17
- return fs.readFileSync(file, "utf8");
18
- } catch (e) {
19
- return "";
20
- }
21
- }
22
-
23
16
  let failures = 0;
24
17
  const fail = (msg) => {
25
18
  failures += 1;
@@ -6,14 +6,14 @@
6
6
  // leaves "most specific wins" unable to pick an owner
7
7
  //
8
8
  // Either is silenced by an unexpired `waived:` row in docs/operations/bookkeeping.md naming its
9
- // kind plus the offending glob (dead-glob) or the shared path (ownership-overlap) verbatim.
9
+ // kind plus the offending glob (dead-glob) or the shared path (ownership-overlap) in backticks.
10
10
  // Exit 1 on anything unwaived.
11
11
  //
12
12
  // A glob at strictly HIGHER specificity carving a sub-path out of a broader `**` is the designed
13
13
  // layering, not a conflict — only an exact tie is reported.
14
14
 
15
15
  import { deadGlobs, listRepoFiles, overlaps } from "./lib/ownership.mjs";
16
- import { readQueue, unexpiredWaivers } from "./lib/state.mjs";
16
+ import { readQueue, waiverCovers } from "./lib/state.mjs";
17
17
 
18
18
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
19
19
  const today = new Date().toISOString().slice(0, 10);
@@ -22,12 +22,10 @@ const files = listRepoFiles(projectDir);
22
22
  const queue = readQueue(projectDir);
23
23
 
24
24
  const dead = deadGlobs(projectDir, files);
25
- const deadWaivers = unexpiredWaivers(queue, "dead-glob", today);
26
- const unwaivedDead = dead.filter((d) => !deadWaivers.some((w) => w.body.includes(d.glob)));
25
+ const unwaivedDead = dead.filter((d) => !waiverCovers(queue, "dead-glob", d.glob, today));
27
26
 
28
27
  const conflicts = overlaps(projectDir, files);
29
- const overlapWaivers = unexpiredWaivers(queue, "ownership-overlap", today);
30
- const unwaivedOverlaps = conflicts.filter((c) => !overlapWaivers.some((w) => w.body.includes(c.example)));
28
+ const unwaivedOverlaps = conflicts.filter((c) => !waiverCovers(queue, "ownership-overlap", c.example, today));
31
29
 
32
30
  if (unwaivedDead.length === 0 && unwaivedOverlaps.length === 0) {
33
31
  console.log(
@@ -39,7 +37,7 @@ if (unwaivedDead.length === 0 && unwaivedOverlaps.length === 0) {
39
37
  if (unwaivedDead.length > 0) {
40
38
  console.error(`check-ownership: ${unwaivedDead.length} unwaived dead owns: glob(s):`);
41
39
  for (const d of unwaivedDead) console.error(` ${d.glob} → ${d.doc}`);
42
- console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row with reason + expires=.");
40
+ console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row naming the glob in backticks, with reason + expires=.");
43
41
  }
44
42
 
45
43
  if (unwaivedOverlaps.length > 0) {
@@ -50,7 +48,7 @@ if (unwaivedOverlaps.length > 0) {
50
48
  console.error(` ${c.b.glob} → ${c.b.doc}`);
51
49
  }
52
50
  console.error(
53
- "Narrow one doc's glob so a single owner wins, or add a `waived:` ownership-overlap row naming the shared path with reason + expires=.",
51
+ "Narrow one doc's glob so a single owner wins, or add a `waived:` ownership-overlap row naming the shared path in backticks with reason + expires=.",
54
52
  );
55
53
  }
56
54