@nurix/etna 0.19.58 → 0.19.60

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": "411f905588e3",
2
+ "fingerprint": "2d248a0cd94a",
3
3
  "harnesses": {
4
4
  "apollo": {
5
5
  "plugin": "apollo",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bookkeeper
3
- description: Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on "drain the bookkeeping queue", "dispatch the bookkeeper", "/bookkeeper".
3
+ description: Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, deletes each row it drains. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on "drain the bookkeeping queue", "dispatch the bookkeeper", "/bookkeeper".
4
4
  tools: Read, Glob, Grep, Edit, Write, Bash, Skill
5
5
  model: sonnet
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  # dropped Claude tools: Read, Glob, Grep, Edit, Write, Bash, Skill
2
2
  name = "bookkeeper"
3
- description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
3
+ description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, deletes each row it drains. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
4
4
  developer_instructions = '''
5
5
 
6
6
  # Bookkeeper Agent
@@ -15,5 +15,5 @@
15
15
  "docs/memory/main.md",
16
16
  ".mcp.json"
17
17
  ],
18
- "digest": "6c0123cfa9ee"
18
+ "digest": "91d17fd2366f"
19
19
  }
@@ -23,6 +23,7 @@ import {
23
23
  readQueue,
24
24
  countRows,
25
25
  formatAutoRow,
26
+ formatNeedsSessionRow,
26
27
  appendRows,
27
28
  resolveRows,
28
29
  unexpiredWaivers,
@@ -245,6 +246,30 @@ function detectorForFile(projectDir, rel, meta, rows) {
245
246
  if (rel.endsWith(".md")) {
246
247
  // An edited doc resolves any doc-implicated row that pointed at it.
247
248
  resolveRows(projectDir, (row) => row.kind === "auto" && row.body.includes("doc-implicated") && row.body.includes(rel));
249
+
250
+ // A plan's Doc Obligations ledger names docs whose stated behavior the plan falsifies. Whether
251
+ // one is discharged is a judgment, never a mechanical fix — so it becomes a `needs-session:`
252
+ // row, the one kind the bookkeeper leaves untouched, rather than an `auto:` row a sub-agent
253
+ // would tick without reading the code. Raising it here rather than walling it at Stop is what
254
+ // gives it the queue's three existing walls (Stop, commit, CI) instead of a fourth special case.
255
+ // Resolve-then-append keeps exactly one row per plan, so re-editing never stacks duplicates.
256
+ if (rel.startsWith("docs/history/plans/")) {
257
+ const planText = readSafe(path.join(projectDir, rel));
258
+ resolveRows(projectDir, (row) => row.kind === "needs-session" && row.body.includes(rel));
259
+ if (
260
+ /^\*\*Status:\*\*\s*Complete\b/m.test(planText) &&
261
+ /^##\s+Doc Obligations\b/m.test(planText) &&
262
+ /^\s*-\s*\[ \]\s*Every Doc Obligations row discharged/m.test(planText)
263
+ ) {
264
+ rows.push(
265
+ formatNeedsSessionRow({
266
+ date: meta.date,
267
+ description: `${rel} reached Status: Complete with its Doc Obligations rows undischarged`,
268
+ refs: rel,
269
+ })
270
+ );
271
+ }
272
+ }
248
273
  if (rel.startsWith("docs/")) {
249
274
  const findings = docLint(projectDir, rel);
250
275
  if (findings.length) {
@@ -23,11 +23,20 @@ const entries = nameStatus
23
23
  });
24
24
 
25
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
+
26
36
  const exemptFile = (f) =>
27
37
  LOCKFILES.test(path.basename(f)) ||
28
- f.startsWith(".claude/") ||
29
- f === "AGENTS.md" ||
30
- f === "CLAUDE.md" ||
38
+ HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
39
+ HARNESS_FILES.includes(f) ||
31
40
  f.startsWith("docs/history/changelog/") ||
32
41
  f === "docs/history/CHANGELOG.md";
33
42
 
@@ -43,6 +52,6 @@ if (isBot || allRenames || allExempt || hasEntry) {
43
52
  }
44
53
  console.error(
45
54
  "check-changelog-presence: HEAD is a non-exempt commit carrying no docs/history/changelog/ entry. " +
46
- "Every non-exempt commit ships its entry inside it (exempt: release/bot, lockfile-only, pure renames, changelog housekeeping)."
55
+ "Every non-exempt commit ships its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping)."
47
56
  );
48
57
  process.exit(1);
@@ -41,13 +41,16 @@ for (const file of plans) {
41
41
  const unchecked = (text.match(/^- \[ \]/gm) || []).length;
42
42
  if (unchecked > 0) fail(`${file} is Complete with ${unchecked} unchecked Done Criteria box(es)`);
43
43
 
44
- const spec = (/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/m.exec(text) || [])[1];
45
- if (spec) {
44
+ // EVERY Spec line, not the first. A plan may implement against several feature docs, and a
45
+ // single-match parse silently exempts all but one — the same singular assumption the Doc
46
+ // Obligations ledger exists to break, reintroduced here where nothing would surface it.
47
+ const specs = [...text.matchAll(/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/gm)].map((m) => m[1]);
48
+ for (const spec of specs) {
46
49
  const specPath = path.resolve(plansDir, spec);
47
50
  const specText = readSafe(specPath);
48
51
  const specStatus = (field(frontmatter(specText), "status") || "").toString();
49
52
  if (specText && specStatus !== "active") {
50
- fail(`${file} is Complete but its Spec LLD is status: ${specStatus || "unknown"} (expected active)`);
53
+ fail(`${file} is Complete but its Spec LLD ${spec} is status: ${specStatus || "unknown"} (expected active)`);
51
54
  }
52
55
  const feature = path.basename(specPath, ".md");
53
56
  if (!fs.existsSync(path.join(projectDir, "docs", "tests", `${feature}.md`))) {
@@ -141,13 +141,22 @@ const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|C
141
141
 
142
142
  // Exempt per the changelog rule's closed list, judged mechanically: lockfile-only,
143
143
  // harness-file-only (.claude/, AGENTS.md, CLAUDE.md), or changelog housekeeping.
144
+ const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
145
+ const HARNESS_FILES = [
146
+ "AGENTS.md",
147
+ "CLAUDE.md",
148
+ ".mcp.json",
149
+ ".vscode/settings.json",
150
+ ".prettierignore",
151
+ ".prettierrc.json",
152
+ ];
153
+
144
154
  function stagedExempt(files) {
145
155
  return files.every(
146
156
  (f) =>
147
157
  LOCKFILES.test(path.basename(f)) ||
148
- f.startsWith(".claude/") ||
149
- f === "AGENTS.md" ||
150
- f === "CLAUDE.md" ||
158
+ HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
159
+ HARNESS_FILES.includes(f) ||
151
160
  f.startsWith("docs/history/changelog/") ||
152
161
  f === "docs/history/CHANGELOG.md"
153
162
  );
@@ -162,7 +171,7 @@ function commitGate(projectDir) {
162
171
  if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
163
172
  return decision(
164
173
  "deny",
165
- "BLOCKED — changelog. This commit stages no `docs/history/changelog/` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run)."
174
+ "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)."
166
175
  );
167
176
  }
168
177
  }
@@ -308,15 +317,6 @@ function plansCompletedThisSession(projectDir, sessionId) {
308
317
  return out;
309
318
  }
310
319
 
311
- // A completed plan whose Doc Obligations ledger was never discharged. Discharge is a judgment the
312
- // hook cannot make — whether a doc was actually updated well is a human read — so what it checks is
313
- // the one mechanical trace the act leaves: the Done Criteria checkbox that says every row was
314
- // examined. Presence of the section is gated at the implementation-edit wall; this is the other end.
315
- function docLedgerUndischarged(text) {
316
- if (!/^##\s+Doc Obligations\b/m.test(text)) return false;
317
- return /^\s*-\s*\[ \]\s*Every Doc Obligations row discharged/m.test(text);
318
- }
319
-
320
320
  // Whether some changelog entry already accounts for this plan.
321
321
  //
322
322
  // COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
@@ -397,19 +397,13 @@ function stopGate(projectDir, payload) {
397
397
  };
398
398
  }
399
399
 
400
- const completed = plansCompletedThisSession(projectDir, payload.session_id);
401
-
402
- const docsOpen = completed.filter((rel) =>
403
- docLedgerUndischarged(readSafe(path.join(projectDir, rel)))
400
+ // An undischarged Doc Obligations ledger is not walled here — the detector raises it as a
401
+ // `needs-session:` row, which the queue wall above already blocks on, and which outlives this
402
+ // stop through the commit gate and CI. A second wall keyed to one checkbox would add a special
403
+ // case beside a general rule that already covers it.
404
+ const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
405
+ (rel) => !planHasChangelogEntry(projectDir, rel)
404
406
  );
405
- if (docsOpen.length > 0) {
406
- return {
407
- decision: "block",
408
- reason: `Plan \`${docsOpen[0]}\` reached Status: Complete with its Doc Obligations rows undischarged. Walk each row — update the doc, or mark it owes nothing with the reason, or send it to the backlog as blocked — then tick the Done Criteria line. The feature docs are the only artifact the next session inherits.`,
409
- };
410
- }
411
-
412
- const undocumented = completed.filter((rel) => !planHasChangelogEntry(projectDir, rel));
413
407
  if (undocumented.length > 0) {
414
408
  return {
415
409
  decision: "block",
@@ -14,7 +14,7 @@ export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
14
14
 
15
15
  export const QUEUE_HEADER = `# Bookkeeping
16
16
 
17
- The shared work queue keeping documentation and code in sync — 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 resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A \`waived:\` row carries \`expires=\` and is PR-reviewable history.
17
+ 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.
18
18
  `;
19
19
 
20
20
  export function queueFile(projectDir) {
@@ -63,6 +63,13 @@ export function formatAutoRow({ date, kind, description, action, sessionId }) {
63
63
  return `- auto: ${date} · ${kind} · ${description} → ${action} | sid=${sessionId || "-"}`;
64
64
  }
65
65
 
66
+ // Format one needs-session: row — a finding detected mechanically that only a session can resolve.
67
+ // It carries no `sid=`: unlike an auto: row it is never drained by the bookkeeper, so the session
68
+ // that raised it is not the one that must answer it.
69
+ export function formatNeedsSessionRow({ date, description, refs }) {
70
+ return `- needs-session: ${date} · ${description} → main session resolves | refs=${refs}`;
71
+ }
72
+
66
73
  // Append rows to the queue, creating the file with its genre header when absent.
67
74
  // Dedupes on the row text before the `| sid=` tail, so a re-fired detector never stacks duplicates.
68
75
  export function appendRows(projectDir, rowLines) {
@@ -7,7 +7,7 @@ The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:`
7
7
  ```
8
8
  # Bookkeeping
9
9
 
10
- The shared work queue keeping documentation and code in sync — 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 resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A `waived:` row carries `expires=` and is PR-reviewable history.
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.
11
11
 
12
12
  - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
13
13
  - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bookkeeper
3
- description: Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on "drain the bookkeeping queue", "dispatch the bookkeeper", "/bookkeeper".
3
+ description: Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, deletes each row it drains. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on "drain the bookkeeping queue", "dispatch the bookkeeper", "/bookkeeper".
4
4
  tools: Read, Glob, Grep, Edit, Write, Bash, Skill
5
5
  model: sonnet
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  # dropped Claude tools: Read, Glob, Grep, Edit, Write, Bash, Skill
2
2
  name = "bookkeeper"
3
- description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
3
+ description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, deletes each row it drains. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
4
4
  developer_instructions = '''
5
5
 
6
6
  # Bookkeeper Agent
@@ -10,5 +10,5 @@
10
10
  "seeds": [
11
11
  ".mcp.json"
12
12
  ],
13
- "digest": "ecbd84f05b8e"
13
+ "digest": "77f419c9b4da"
14
14
  }
@@ -23,6 +23,7 @@ import {
23
23
  readQueue,
24
24
  countRows,
25
25
  formatAutoRow,
26
+ formatNeedsSessionRow,
26
27
  appendRows,
27
28
  resolveRows,
28
29
  unexpiredWaivers,
@@ -245,6 +246,30 @@ function detectorForFile(projectDir, rel, meta, rows) {
245
246
  if (rel.endsWith(".md")) {
246
247
  // An edited doc resolves any doc-implicated row that pointed at it.
247
248
  resolveRows(projectDir, (row) => row.kind === "auto" && row.body.includes("doc-implicated") && row.body.includes(rel));
249
+
250
+ // A plan's Doc Obligations ledger names docs whose stated behavior the plan falsifies. Whether
251
+ // one is discharged is a judgment, never a mechanical fix — so it becomes a `needs-session:`
252
+ // row, the one kind the bookkeeper leaves untouched, rather than an `auto:` row a sub-agent
253
+ // would tick without reading the code. Raising it here rather than walling it at Stop is what
254
+ // gives it the queue's three existing walls (Stop, commit, CI) instead of a fourth special case.
255
+ // Resolve-then-append keeps exactly one row per plan, so re-editing never stacks duplicates.
256
+ if (rel.startsWith("docs/history/plans/")) {
257
+ const planText = readSafe(path.join(projectDir, rel));
258
+ resolveRows(projectDir, (row) => row.kind === "needs-session" && row.body.includes(rel));
259
+ if (
260
+ /^\*\*Status:\*\*\s*Complete\b/m.test(planText) &&
261
+ /^##\s+Doc Obligations\b/m.test(planText) &&
262
+ /^\s*-\s*\[ \]\s*Every Doc Obligations row discharged/m.test(planText)
263
+ ) {
264
+ rows.push(
265
+ formatNeedsSessionRow({
266
+ date: meta.date,
267
+ description: `${rel} reached Status: Complete with its Doc Obligations rows undischarged`,
268
+ refs: rel,
269
+ })
270
+ );
271
+ }
272
+ }
248
273
  if (rel.startsWith("docs/")) {
249
274
  const findings = docLint(projectDir, rel);
250
275
  if (findings.length) {
@@ -23,11 +23,20 @@ const entries = nameStatus
23
23
  });
24
24
 
25
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
+
26
36
  const exemptFile = (f) =>
27
37
  LOCKFILES.test(path.basename(f)) ||
28
- f.startsWith(".claude/") ||
29
- f === "AGENTS.md" ||
30
- f === "CLAUDE.md" ||
38
+ HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
39
+ HARNESS_FILES.includes(f) ||
31
40
  f.startsWith("docs/history/changelog/") ||
32
41
  f === "docs/history/CHANGELOG.md";
33
42
 
@@ -43,6 +52,6 @@ if (isBot || allRenames || allExempt || hasEntry) {
43
52
  }
44
53
  console.error(
45
54
  "check-changelog-presence: HEAD is a non-exempt commit carrying no docs/history/changelog/ entry. " +
46
- "Every non-exempt commit ships its entry inside it (exempt: release/bot, lockfile-only, pure renames, changelog housekeeping)."
55
+ "Every non-exempt commit ships its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping)."
47
56
  );
48
57
  process.exit(1);
@@ -41,13 +41,16 @@ for (const file of plans) {
41
41
  const unchecked = (text.match(/^- \[ \]/gm) || []).length;
42
42
  if (unchecked > 0) fail(`${file} is Complete with ${unchecked} unchecked Done Criteria box(es)`);
43
43
 
44
- const spec = (/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/m.exec(text) || [])[1];
45
- if (spec) {
44
+ // EVERY Spec line, not the first. A plan may implement against several feature docs, and a
45
+ // single-match parse silently exempts all but one — the same singular assumption the Doc
46
+ // Obligations ledger exists to break, reintroduced here where nothing would surface it.
47
+ const specs = [...text.matchAll(/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/gm)].map((m) => m[1]);
48
+ for (const spec of specs) {
46
49
  const specPath = path.resolve(plansDir, spec);
47
50
  const specText = readSafe(specPath);
48
51
  const specStatus = (field(frontmatter(specText), "status") || "").toString();
49
52
  if (specText && specStatus !== "active") {
50
- fail(`${file} is Complete but its Spec LLD is status: ${specStatus || "unknown"} (expected active)`);
53
+ fail(`${file} is Complete but its Spec LLD ${spec} is status: ${specStatus || "unknown"} (expected active)`);
51
54
  }
52
55
  const feature = path.basename(specPath, ".md");
53
56
  if (!fs.existsSync(path.join(projectDir, "docs", "tests", `${feature}.md`))) {
@@ -141,13 +141,22 @@ const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|C
141
141
 
142
142
  // Exempt per the changelog rule's closed list, judged mechanically: lockfile-only,
143
143
  // harness-file-only (.claude/, AGENTS.md, CLAUDE.md), or changelog housekeeping.
144
+ const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
145
+ const HARNESS_FILES = [
146
+ "AGENTS.md",
147
+ "CLAUDE.md",
148
+ ".mcp.json",
149
+ ".vscode/settings.json",
150
+ ".prettierignore",
151
+ ".prettierrc.json",
152
+ ];
153
+
144
154
  function stagedExempt(files) {
145
155
  return files.every(
146
156
  (f) =>
147
157
  LOCKFILES.test(path.basename(f)) ||
148
- f.startsWith(".claude/") ||
149
- f === "AGENTS.md" ||
150
- f === "CLAUDE.md" ||
158
+ HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
159
+ HARNESS_FILES.includes(f) ||
151
160
  f.startsWith("docs/history/changelog/") ||
152
161
  f === "docs/history/CHANGELOG.md"
153
162
  );
@@ -162,7 +171,7 @@ function commitGate(projectDir) {
162
171
  if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
163
172
  return decision(
164
173
  "deny",
165
- "BLOCKED — changelog. This commit stages no `docs/history/changelog/` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run)."
174
+ "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)."
166
175
  );
167
176
  }
168
177
  }
@@ -308,15 +317,6 @@ function plansCompletedThisSession(projectDir, sessionId) {
308
317
  return out;
309
318
  }
310
319
 
311
- // A completed plan whose Doc Obligations ledger was never discharged. Discharge is a judgment the
312
- // hook cannot make — whether a doc was actually updated well is a human read — so what it checks is
313
- // the one mechanical trace the act leaves: the Done Criteria checkbox that says every row was
314
- // examined. Presence of the section is gated at the implementation-edit wall; this is the other end.
315
- function docLedgerUndischarged(text) {
316
- if (!/^##\s+Doc Obligations\b/m.test(text)) return false;
317
- return /^\s*-\s*\[ \]\s*Every Doc Obligations row discharged/m.test(text);
318
- }
319
-
320
320
  // Whether some changelog entry already accounts for this plan.
321
321
  //
322
322
  // COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
@@ -397,19 +397,13 @@ function stopGate(projectDir, payload) {
397
397
  };
398
398
  }
399
399
 
400
- const completed = plansCompletedThisSession(projectDir, payload.session_id);
401
-
402
- const docsOpen = completed.filter((rel) =>
403
- docLedgerUndischarged(readSafe(path.join(projectDir, rel)))
400
+ // An undischarged Doc Obligations ledger is not walled here — the detector raises it as a
401
+ // `needs-session:` row, which the queue wall above already blocks on, and which outlives this
402
+ // stop through the commit gate and CI. A second wall keyed to one checkbox would add a special
403
+ // case beside a general rule that already covers it.
404
+ const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
405
+ (rel) => !planHasChangelogEntry(projectDir, rel)
404
406
  );
405
- if (docsOpen.length > 0) {
406
- return {
407
- decision: "block",
408
- reason: `Plan \`${docsOpen[0]}\` reached Status: Complete with its Doc Obligations rows undischarged. Walk each row — update the doc, or mark it owes nothing with the reason, or send it to the backlog as blocked — then tick the Done Criteria line. The feature docs are the only artifact the next session inherits.`,
409
- };
410
- }
411
-
412
- const undocumented = completed.filter((rel) => !planHasChangelogEntry(projectDir, rel));
413
407
  if (undocumented.length > 0) {
414
408
  return {
415
409
  decision: "block",
@@ -14,7 +14,7 @@ export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
14
14
 
15
15
  export const QUEUE_HEADER = `# Bookkeeping
16
16
 
17
- The shared work queue keeping documentation and code in sync — 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 resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A \`waived:\` row carries \`expires=\` and is PR-reviewable history.
17
+ 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.
18
18
  `;
19
19
 
20
20
  export function queueFile(projectDir) {
@@ -63,6 +63,13 @@ export function formatAutoRow({ date, kind, description, action, sessionId }) {
63
63
  return `- auto: ${date} · ${kind} · ${description} → ${action} | sid=${sessionId || "-"}`;
64
64
  }
65
65
 
66
+ // Format one needs-session: row — a finding detected mechanically that only a session can resolve.
67
+ // It carries no `sid=`: unlike an auto: row it is never drained by the bookkeeper, so the session
68
+ // that raised it is not the one that must answer it.
69
+ export function formatNeedsSessionRow({ date, description, refs }) {
70
+ return `- needs-session: ${date} · ${description} → main session resolves | refs=${refs}`;
71
+ }
72
+
66
73
  // Append rows to the queue, creating the file with its genre header when absent.
67
74
  // Dedupes on the row text before the `| sid=` tail, so a re-fired detector never stacks duplicates.
68
75
  export function appendRows(projectDir, rowLines) {
@@ -7,7 +7,7 @@ The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:`
7
7
  ```
8
8
  # Bookkeeping
9
9
 
10
- The shared work queue keeping documentation and code in sync — 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 resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A `waived:` row carries `expires=` and is PR-reviewable history.
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.
11
11
 
12
12
  - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
13
13
  - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
@@ -8,5 +8,5 @@
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "a2c8df3022bd"
11
+ "digest": "9200d5f0c074"
12
12
  }
@@ -7,7 +7,7 @@ The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:`
7
7
  ```
8
8
  # Bookkeeping
9
9
 
10
- The shared work queue keeping documentation and code in sync — 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 resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A `waived:` row carries `expires=` and is PR-reviewable history.
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.
11
11
 
12
12
  - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
13
13
  - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
@@ -8,5 +8,5 @@
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "171db7dbad6a"
11
+ "digest": "dbb7424aa7c7"
12
12
  }
@@ -7,7 +7,7 @@ The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:`
7
7
  ```
8
8
  # Bookkeeping
9
9
 
10
- The shared work queue keeping documentation and code in sync — 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 resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A `waived:` row carries `expires=` and is PR-reviewable history.
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.
11
11
 
12
12
  - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
13
13
  - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.58",
3
+ "version": "0.19.60",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "411f905588e3"
45
+ "etnaFingerprint": "2d248a0cd94a"
46
46
  }