@nurix/etna 0.19.64 → 0.19.66

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 (63) hide show
  1. package/bin/etna.js +155 -557
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/apollo/CLAUDE.md +1 -1
  4. package/data/harnesses/apollo/agents/maintenance.md +1 -0
  5. package/data/harnesses/apollo/harness.json +2 -2
  6. package/data/harnesses/apollo/hooks/approved-packages.md +2 -0
  7. package/data/harnesses/apollo/rules/changelog.md +2 -0
  8. package/data/harnesses/apollo/skills/documentation/SKILL.md +1 -0
  9. package/data/harnesses/apollo/skills/documentation/references/frontmatter.md +1 -1
  10. package/data/harnesses/apollo/skills/documentation/references/style-guide.md +34 -1
  11. package/data/harnesses/apollo/skills/documentation/scripts/doc-lint.mjs +1 -1
  12. package/data/harnesses/apollo/skills/documentation/templates/Readme.md +1 -0
  13. package/data/harnesses/apollo/skills/documentation/templates/behavioral-checklist.md +2 -0
  14. package/data/harnesses/apollo/skills/documentation/templates/changelog-entry.md +2 -2
  15. package/data/harnesses/apollo/skills/documentation/templates/feature.md +1 -0
  16. package/data/harnesses/apollo/skills/documentation/templates/implementation-map.md +42 -0
  17. package/data/harnesses/apollo/skills/testing/SKILL.md +5 -5
  18. package/data/harnesses/code/CLAUDE.md +1 -1
  19. package/data/harnesses/code/agents/maintenance.md +1 -0
  20. package/data/harnesses/code/harness.json +2 -2
  21. package/data/harnesses/code/hooks/approved-packages.md +2 -0
  22. package/data/harnesses/code/hooks/check-completion-integrity.mjs +8 -0
  23. package/data/harnesses/code/hooks/compliance.mjs +138 -27
  24. package/data/harnesses/code/hooks/lib/ledger.mjs +31 -0
  25. package/data/harnesses/code/hooks/lib/session-log.mjs +75 -0
  26. package/data/harnesses/code/rules/changelog.md +2 -0
  27. package/data/harnesses/code/skills/documentation/SKILL.md +1 -0
  28. package/data/harnesses/code/skills/documentation/references/frontmatter.md +1 -1
  29. package/data/harnesses/code/skills/documentation/references/style-guide.md +34 -1
  30. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +1 -1
  31. package/data/harnesses/code/skills/documentation/templates/Readme.md +1 -0
  32. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +2 -0
  33. package/data/harnesses/code/skills/documentation/templates/changelog-entry.md +2 -2
  34. package/data/harnesses/code/skills/documentation/templates/feature.md +1 -0
  35. package/data/harnesses/code/skills/documentation/templates/implementation-map.md +42 -0
  36. package/data/harnesses/code/skills/testing/SKILL.md +5 -5
  37. package/data/harnesses/delivery/agents/maintenance.md +1 -0
  38. package/data/harnesses/delivery/harness.json +2 -2
  39. package/data/harnesses/delivery/rules/changelog.md +2 -0
  40. package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -0
  41. package/data/harnesses/delivery/skills/documentation/references/frontmatter.md +1 -1
  42. package/data/harnesses/delivery/skills/documentation/references/style-guide.md +34 -1
  43. package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +1 -1
  44. package/data/harnesses/delivery/skills/documentation/templates/Readme.md +1 -0
  45. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +2 -0
  46. package/data/harnesses/delivery/skills/documentation/templates/changelog-entry.md +2 -2
  47. package/data/harnesses/delivery/skills/documentation/templates/feature.md +1 -0
  48. package/data/harnesses/delivery/skills/documentation/templates/implementation-map.md +42 -0
  49. package/data/harnesses/etna/agents/maintenance.md +1 -0
  50. package/data/harnesses/etna/harness.json +2 -2
  51. package/data/harnesses/etna/rules/changelog.md +2 -0
  52. package/data/harnesses/etna/skills/documentation/SKILL.md +1 -0
  53. package/data/harnesses/etna/skills/documentation/references/frontmatter.md +1 -1
  54. package/data/harnesses/etna/skills/documentation/references/style-guide.md +34 -1
  55. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +1 -1
  56. package/data/harnesses/etna/skills/documentation/templates/Readme.md +1 -0
  57. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +2 -0
  58. package/data/harnesses/etna/skills/documentation/templates/changelog-entry.md +2 -2
  59. package/data/harnesses/etna/skills/documentation/templates/feature.md +1 -0
  60. package/data/harnesses/etna/skills/documentation/templates/implementation-map.md +42 -0
  61. package/lib/install/materialize.mjs +402 -0
  62. package/lib/install/plan.mjs +271 -0
  63. package/package.json +2 -2
@@ -11,6 +11,8 @@ import { spawnSync } from "node:child_process";
11
11
  import { readStdin, resolveProjectDir, frontmatter } from "./lib/ownership.mjs";
12
12
  import { readQueue, countRows, askedThisSession, recordAsked } from "./lib/state.mjs";
13
13
  import { frozenStatus } from "./lib/frozen.mjs";
14
+ import { undischargedRows } from "./lib/ledger.mjs";
15
+ import { appendEvent, unresolvedDenials, sessionLogPath } from "./lib/session-log.mjs";
14
16
 
15
17
  function readSafe(file) {
16
18
  try {
@@ -38,6 +40,23 @@ function decision(permissionDecision, reason) {
38
40
  };
39
41
  }
40
42
 
43
+ // A deny is also an event in the session gate log — the record that makes going silent after a
44
+ // block impossible. The log is a dated per-session record (never drained; bookkeeping stays a
45
+ // queue); the Stop and commit walls block on `denied` lines with no later `resolved` pair.
46
+ function deny(projectDir, sessionId, gate, target, reason) {
47
+ appendEvent(projectDir, sessionId, "denied", gate, target);
48
+ return decision("deny", reason);
49
+ }
50
+
51
+ // The mechanical half of resolution: when the once-denied operation later succeeds through the
52
+ // gate, machinery writes `retried-ok` itself — the session hand-writes only the judgment claims
53
+ // (`waived → {ref}`, `disclosed → {entry}`).
54
+ function autoResolve(projectDir, sessionId, gate, target) {
55
+ if (unresolvedDenials(projectDir, sessionId).includes(`${gate} · ${target}`)) {
56
+ appendEvent(projectDir, sessionId, "resolved", gate, target, "retried-ok");
57
+ }
58
+ }
59
+
41
60
  // ---------------------------------------------------------------------------
42
61
  // PreToolUse — file gates
43
62
  // ---------------------------------------------------------------------------
@@ -83,40 +102,54 @@ function openGatedPlans(projectDir) {
83
102
 
84
103
  const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
85
104
 
86
- function fileGate(projectDir, rel, toolName) {
105
+ function fileGate(projectDir, rel, toolName, sessionId) {
87
106
  const frozen = frozenStatus(projectDir, rel);
88
107
  if (frozen.frozen) {
89
- return decision(
90
- "deny",
108
+ return deny(
109
+ projectDir,
110
+ sessionId,
111
+ "frozen-record",
112
+ rel,
91
113
  `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}). A correction is a new dated entry that links back.`
92
114
  );
93
115
  }
94
116
 
95
117
  if (protectedPaths(projectDir).includes(rel)) {
96
- return decision("deny", `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
118
+ return deny(projectDir, sessionId, "protected-file", rel, `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
97
119
  }
98
120
 
99
121
  const inDocs = rel.startsWith("docs/") || rel.startsWith(".claude/") || rel.endsWith(".md");
100
122
  if (!inDocs && SOURCE_LIKE.test(rel)) {
101
123
  for (const plan of openGatedPlans(projectDir)) {
102
124
  if (/Boundary:\s*pending/i.test(plan.gates)) {
103
- return decision(
104
- "deny",
125
+ return deny(
126
+ projectDir,
127
+ sessionId,
128
+ "plan-gate",
129
+ plan.file,
105
130
  `BLOCKED — plan gate. \`${plan.file}\` is open but its Gates line reads 'Boundary: pending'. Run the \`boundary\` skill and record its verdict in the plan before implementation edits.`
106
131
  );
107
132
  }
108
133
  if (!plan.hasLedger) {
109
- return decision(
110
- "deny",
134
+ return deny(
135
+ projectDir,
136
+ sessionId,
137
+ "plan-gate",
138
+ plan.file,
111
139
  `BLOCKED — plan gate. \`${plan.file}\` has no \`## Test Obligations\` section. Run the \`testing\` skill (plan-gate mode); its ledger is what the verification gate discharges.`
112
140
  );
113
141
  }
114
142
  if (!plan.hasDocLedger) {
115
- return decision(
116
- "deny",
143
+ return deny(
144
+ projectDir,
145
+ sessionId,
146
+ "plan-gate",
147
+ plan.file,
117
148
  `BLOCKED — plan gate. \`${plan.file}\` has no \`## Doc Obligations\` section. Resolve the Proposed Changes paths against the ownership index and fill the table yourself — it is a lookup, not a skill run. "None" is a valid row when the plan touches only unowned paths.`
118
149
  );
119
150
  }
151
+ // The gates pass now — a plan-gate denial recorded earlier is a successful retry.
152
+ autoResolve(projectDir, sessionId, "plan-gate", plan.file);
120
153
  }
121
154
  }
122
155
 
@@ -158,30 +191,51 @@ function stagedExempt(files) {
158
191
  HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
159
192
  HARNESS_FILES.includes(f) ||
160
193
  f.startsWith("docs/history/changelog/") ||
194
+ f.startsWith("docs/history/sessions/") ||
161
195
  f === "docs/history/CHANGELOG.md"
162
196
  );
163
197
  }
164
198
 
165
- function commitGate(projectDir) {
199
+ function commitGate(projectDir, sessionId) {
166
200
  const staged = (git(projectDir, ["diff", "--cached", "--name-only"]) || "")
167
201
  .split("\n")
168
202
  .map((s) => s.trim())
169
203
  .filter(Boolean);
170
204
  if (staged.length > 0 && !stagedExempt(staged)) {
171
205
  if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
172
- return decision(
173
- "deny",
206
+ return deny(
207
+ projectDir,
208
+ sessionId,
209
+ "changelog",
210
+ "git commit",
174
211
  "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)."
175
212
  );
176
213
  }
177
214
  }
178
215
  const counts = countRows(readQueue(projectDir));
179
216
  if (counts.unresolved > 0) {
217
+ return deny(
218
+ projectDir,
219
+ sessionId,
220
+ "bookkeeping",
221
+ "git commit",
222
+ `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.`
223
+ );
224
+ }
225
+
226
+ // This session's own open denials wall the commit too — a commit is the arc's end as much as a
227
+ // stop is, and an undisclosed failure must not ride out inside one.
228
+ const open = unresolvedDenials(projectDir, sessionId).filter((pair) => !pair.startsWith("changelog ·") && !pair.startsWith("bookkeeping ·"));
229
+ if (open.length > 0) {
180
230
  return decision(
181
231
  "deny",
182
- `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.`
232
+ `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.`
183
233
  );
184
234
  }
235
+
236
+ // All commit gates pass — a changelog/bookkeeping denial recorded earlier is a successful retry.
237
+ autoResolve(projectDir, sessionId, "changelog", "git commit");
238
+ autoResolve(projectDir, sessionId, "bookkeeping", "git commit");
185
239
  return null;
186
240
  }
187
241
 
@@ -200,22 +254,40 @@ function approvedPackages(projectDir) {
200
254
  return names;
201
255
  }
202
256
 
203
- function packageGate(projectDir, command) {
204
- const m = new RegExp(String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)\s+(.+)`).exec(command);
257
+ // A token must parse as an npm package spec — name or @scope/name, optional @range tail —
258
+ // before it may be judged against the table. Everything else on an install line (redirections
259
+ // like 2>&1, chained commands, flags, paths, URLs) is shell, not a package. A non-candidate is
260
+ // skipped, never blocked: a miss is caught downstream by the lockfile, while a false deny
261
+ // teaches the session the gate is noise exactly where the gate matters most.
262
+ const PKG_SPEC = /^(@[a-z0-9~-][\w.-]*\/)?[a-z0-9~-][\w.-]*(@[\w.^~*<>=|x-]+)?$/;
263
+
264
+ function packageGate(projectDir, command, sessionId) {
265
+ const m = new RegExp(
266
+ String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|]*)`
267
+ ).exec(command);
205
268
  if (!m) return null;
206
269
  const approved = approvedPackages(projectDir);
207
270
  if (approved.size === 0) return null; // no rules file installed — nothing to enforce
208
- const args = m[1].split(/\s+/).filter((a) => a && !a.startsWith("-"));
271
+ const args = (m[1] || "")
272
+ .split(/\s+/)
273
+ .filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a));
209
274
  for (const arg of args) {
210
275
  const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
211
- if (!pkg || pkg === "." || pkg.includes("/") && !pkg.startsWith("@")) continue;
212
276
  if (!approved.has(pkg)) {
213
- return decision(
214
- "deny",
277
+ return deny(
278
+ projectDir,
279
+ sessionId,
280
+ "package-table",
281
+ pkg,
215
282
  `BLOCKED — package table. \`${pkg}\` is not in the approved table (.claude/hooks/approved-packages.md; test packages: .claude/rules/testing.md). The table changes only by explicit user decision.`
216
283
  );
217
284
  }
218
285
  }
286
+ // Every named package is approved — a package-table denial for one of them is a successful retry.
287
+ for (const arg of args) {
288
+ const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
289
+ autoResolve(projectDir, sessionId, "package-table", pkg);
290
+ }
219
291
  return null;
220
292
  }
221
293
 
@@ -252,7 +324,7 @@ function bashGate(projectDir, command, sessionId) {
252
324
  if (!command) return null;
253
325
 
254
326
  if (statement(String.raw`git\s+commit\b`).test(command)) {
255
- return commitGate(projectDir);
327
+ return commitGate(projectDir, sessionId);
256
328
  }
257
329
 
258
330
  // `git branch <name>` creates; flag-led forms (--show-current, --list, -d, -vv) query or manage
@@ -277,7 +349,7 @@ function bashGate(projectDir, command, sessionId) {
277
349
  );
278
350
  }
279
351
 
280
- return packageGate(projectDir, command);
352
+ return packageGate(projectDir, command, sessionId);
281
353
  }
282
354
 
283
355
  // ---------------------------------------------------------------------------
@@ -397,13 +469,44 @@ function stopGate(projectDir, payload) {
397
469
  };
398
470
  }
399
471
 
472
+ // Open gate denials wall the stop: silence after a block is the one ending the log exists to
473
+ // make impossible. Resolution appends to the log — retried-ok is machinery-written on a
474
+ // successful retry; waived/disclosed are the session's hand-written, auditable claims.
475
+ const openDenials = unresolvedDenials(projectDir, payload.session_id);
476
+ if (openDenials.length > 0) {
477
+ const rel = path.relative(projectDir, sessionLogPath(projectDir, payload.session_id));
478
+ return {
479
+ decision: "block",
480
+ reason:
481
+ `${openDenials.length} gate denial(s) this session have no resolved line in ${rel}: ${openDenials.join("; ")}. ` +
482
+ "For each, either retry the operation properly (a success appends `retried-ok` automatically), convert it to a waiver and append " +
483
+ "`resolved · {gate} · {target} · waived → {ref}`, or record the failure honestly in the changelog entry and append " +
484
+ "`resolved · {gate} · {target} · disclosed → {entry file}`. Never delete log lines — the log is a record, not a queue.",
485
+ };
486
+ }
487
+
400
488
  // An undischarged Doc Obligations ledger is not walled here — the detector raises it as a
401
489
  // `needs-session:` row, which the queue wall above already blocks on, and which outlives this
402
490
  // stop through the commit gate and CI. A second wall keyed to one checkbox would add a special
403
491
  // case beside a general rule that already covers it.
404
- const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
405
- (rel) => !planHasChangelogEntry(projectDir, rel)
406
- );
492
+ const completed = plansCompletedThisSession(projectDir, payload.session_id);
493
+
494
+ // The Test Obligations ledger IS walled per row: the Outcome cell lives in the plan file, so the
495
+ // only way past this block is reading the file — a context-held copy of the ledger is exactly
496
+ // how rows get missed. (Tables without an Outcome column are legacy and pass; see lib/ledger.)
497
+ for (const rel of completed) {
498
+ const rows = undischargedRows(readSafe(path.join(projectDir, rel)));
499
+ if (rows.length > 0) {
500
+ return {
501
+ decision: "block",
502
+ reason:
503
+ `Plan \`${rel}\` is Status: Complete with ${rows.length} Test Obligations row(s) missing a discharge outcome (#${rows.join(", #")}). ` +
504
+ "Re-read the ledger from the plan file and discharge every row at the verification gate — satisfied, owes nothing (reason), or blocked → backlog — before completing.",
505
+ };
506
+ }
507
+ }
508
+
509
+ const undocumented = completed.filter((rel) => !planHasChangelogEntry(projectDir, rel));
407
510
  if (undocumented.length > 0) {
408
511
  return {
409
512
  decision: "block",
@@ -434,7 +537,7 @@ function main() {
434
537
  out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
435
538
  } else {
436
539
  const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
437
- if (rel) out = fileGate(projectDir, rel, payload.tool_name);
540
+ if (rel) out = fileGate(projectDir, rel, payload.tool_name, payload.session_id);
438
541
  }
439
542
  } else if (event === "Stop") {
440
543
  out = stopGate(projectDir, payload);
@@ -451,7 +554,15 @@ function main() {
451
554
  try {
452
555
  main();
453
556
  } catch (e) {
454
- // Fail open: a broken gate must never become an outage.
557
+ // Fail open: a broken gate must never become an outage. But a gate that crashes silently is a
558
+ // gate nobody knows stopped guarding — leave a trace where the next SessionStart can find it.
559
+ try {
560
+ const dir = path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), ".claude", "state");
561
+ fs.mkdirSync(dir, { recursive: true });
562
+ fs.appendFileSync(path.join(dir, "hook-failures.log"), `${new Date().toISOString()} compliance.mjs ${e?.message || e}\n`);
563
+ } catch (e2) {
564
+ /* truly nowhere to write — fail open stays absolute */
565
+ }
455
566
  } finally {
456
567
  process.exit(0);
457
568
  }
@@ -0,0 +1,31 @@
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
4
+ // forces the gate to re-read the ledger from disk. Tables without an Outcome column predate the
5
+ // contract and are out of scope: the same legacy tolerance the Gates: line gets, so an upgrade
6
+ // cannot brick a plan already Complete.
7
+
8
+ const OUTCOME = /^(satisfied|owes nothing|blocked)\b/i;
9
+
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 [];
14
+ const body = [];
15
+ for (const line of planText.slice(start).split("\n").slice(1)) {
16
+ if (/^##\s/.test(line)) break; // next section
17
+ body.push(line);
18
+ }
19
+ const rows = body.filter((line) => /^\s*\|/.test(line));
20
+ if (rows.length < 3) return []; // header + divider + at least one data row
21
+ const header = rows[0].split("|").map((cell) => cell.trim().toLowerCase());
22
+ const col = header.indexOf("outcome");
23
+ if (col === -1) return []; // legacy table — predates the Outcome contract
24
+ const out = [];
25
+ for (const line of rows.slice(2)) {
26
+ const cells = line.split("|").map((cell) => cell.trim());
27
+ if (!/^\d+$/.test(cells[1] || "")) continue;
28
+ if (!OUTCOME.test(cells[col] || "")) out.push(cells[1]);
29
+ }
30
+ return out;
31
+ }
@@ -0,0 +1,75 @@
1
+ // harness/code/hooks/lib/session-log.mjs — the per-session gate log: a dated record, never drained.
2
+ // Bookkeeping is a queue whose success state is empty; this file is the opposite genre — the
3
+ // session's operational story, kept like memory. Machinery appends `denied` and `hook-crash`
4
+ // events; the session appends `resolved` lines. The walls read PAIRS: a `denied` with no later
5
+ // `resolved` for the same gate·target is an open failure, and silence about it is what the Stop
6
+ // and commit walls refuse. Every line survives forever — the row's afterlife is the failure
7
+ // stream later intelligence reads, so resolution appends rather than deletes.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const DIR = ["docs", "history", "sessions"];
13
+
14
+ function sid8(sessionId) {
15
+ return String(sessionId || "unknown").replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8) || "unknown";
16
+ }
17
+
18
+ export function sessionLogPath(projectDir, sessionId) {
19
+ const dir = path.join(projectDir, ...DIR);
20
+ const suffix = `-${sid8(sessionId)}.md`;
21
+ try {
22
+ const existing = fs.readdirSync(dir).find((f) => f.endsWith(suffix));
23
+ if (existing) return path.join(dir, existing);
24
+ } catch (e) {
25
+ /* dir absent — first event creates it */
26
+ }
27
+ const date = new Date().toISOString().slice(0, 10);
28
+ return path.join(dir, `${date}${suffix}`);
29
+ }
30
+
31
+ // Append one event line, creating the file (with its header) on first write. Best-effort by
32
+ // design: a log append must never become an outage, so failures return false and are the
33
+ // caller's cue to fall back to .claude/state/hook-failures.log.
34
+ export function appendEvent(projectDir, sessionId, kind, gate, target, detail = "") {
35
+ try {
36
+ const file = sessionLogPath(projectDir, sessionId);
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ if (!fs.existsSync(file)) {
39
+ fs.writeFileSync(
40
+ file,
41
+ `# Session gate log · sid=${sessionId || "unknown"}\n\n` +
42
+ "Machinery-written record (a dated record, never edited down — resolutions append). " +
43
+ "Every `denied` line needs a later `resolved` line for the same gate · target — " +
44
+ "`retried-ok`, `waived → {ref}`, or `disclosed → {changelog entry}` — before the " +
45
+ "session ends or commits.\n\n"
46
+ );
47
+ }
48
+ const ts = new Date().toISOString().slice(11, 19);
49
+ const tail = detail ? ` · ${detail}` : "";
50
+ fs.appendFileSync(file, `- ${ts} ${kind} · ${gate} · ${target}${tail}\n`);
51
+ return true;
52
+ } catch (e) {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ // Open failures: gate·target pairs denied with no later `resolved` line. Multiple denials of one
58
+ // pair are one open failure (the retry count stays in the story; the wall asks one question).
59
+ export function unresolvedDenials(projectDir, sessionId) {
60
+ let text = "";
61
+ try {
62
+ text = fs.readFileSync(sessionLogPath(projectDir, sessionId), "utf8");
63
+ } catch (e) {
64
+ return [];
65
+ }
66
+ const open = new Map();
67
+ for (const line of text.split("\n")) {
68
+ const m = /^- \S+ (denied|resolved) · ([^·]+) · ([^·\n]+?)(?: · |$)/.exec(line);
69
+ if (!m) continue;
70
+ const key = `${m[2].trim()} · ${m[3].trim()}`;
71
+ if (m[1] === "denied") open.set(key, true);
72
+ else open.delete(key);
73
+ }
74
+ return [...open.keys()];
75
+ }
@@ -12,6 +12,8 @@ Every unit of work leaves one record — `docs/history/changelog/YYYY-MM-DD-{thr
12
12
 
13
13
  An entry is owed only by work that changes promised behavior or takes a design decision; mechanical and cosmetic commits — a typo, a copy tweak, a config value, a log line — take only their `docs/history/CHANGELOG.md` row, no entry file. The failure fields (`Ask:`, `Skills:`, `Pin:`) are required only when non-none.
14
14
 
15
+ Route the lesson by who pays for losing it: product context — what changed and why — belongs in the entry; anything the **next session would re-pay** — a library's API drifted from what the model expects, a validator's quirk, a tooling workaround — goes to `docs/memory/main.md` as well, because a changelog is read by people tracing history and memory is read by every session before it repeats the mistake.
16
+
15
17
  ## Lifecycle
16
18
 
17
19
  Draft (created before its commit exists) → complete (missing fields filled from the diff and the session) → frozen the moment it rides in a commit. **Immutability begins at commit, not at creation** — a draft is meant to be edited, which is what lets a completed plan record its context before anything is committed; a committed entry is history, and a correction is a new entry linking back, **never** an edit.
@@ -73,6 +73,7 @@ The two guarantees are complementary and both break on mixing: a reader holding
73
73
  | Testing strategy | [`templates/testing-strategy.md`](templates/testing-strategy.md) |
74
74
  | Behavioral checklist for one feature (`docs/tests/{feature}.md`) | [`templates/behavioral-checklist.md`](templates/behavioral-checklist.md) — the verification gate's driving script, and the source its suite tests are drawn from |
75
75
  | Recurring procedure/setup | [`templates/operations-procedure.md`](templates/operations-procedure.md) |
76
+ | Cold-start build order (implementation map) | [`templates/implementation-map.md`](templates/implementation-map.md) — authored by the executing session at build start, updated in place as waves complete |
76
77
  | Capture mid-execution knowledge (`docs/history/journal.md`) | [`templates/journal.md`](templates/journal.md) (record — append-only; a row resolves by landing in its owning doc) |
77
78
  | Bookkeeping work queue (`docs/operations/bookkeeping.md`) | [`templates/bookkeeping.md`](templates/bookkeeping.md) — machine-read row grammar; appended by hooks and sessions, drained by the `bookkeeper` agent, walled at Stop and commit |
78
79
  | One-shot migration/upgrade | [`templates/migration-runbook.md`](templates/migration-runbook.md) (record) |
@@ -12,7 +12,7 @@ When creating or updating a feature doc, derive its **`owns:` frontmatter** —
12
12
 
13
13
  ## How to derive the frontmatter
14
14
 
15
- A feature doc's ownership frontmatter is `owns:` (required), `module:` (optional), `keywords:` (required), and `status:` (required). An optional `last-reviewed:` date (stamped whenever the doc is substantively reviewed or updated) gives the `maintenance` agent a staleness signal to rank its audits by — it is tooling metadata, never narrated in the body.
15
+ A feature doc's ownership frontmatter is `owns:` (required), `module:` (optional), `keywords:` (required), and `status:` (required). An optional `last-reviewed:` date (stamped whenever the doc is substantively reviewed or updated) gives the `maintenance` agent a staleness signal to rank its audits by — it is tooling metadata, never narrated in the body. `type:` (required — the genre, enum = the template names) and `rev:` (generator-written generation stamp) are the same class: external tooling reads them, sessions **preserve them on edits** and never narrate or inject them. The `maintenance` agent stamps `type:` on docs that predate the key.
16
16
 
17
17
  **1. `owns:`** (the path scope)
18
18
 
@@ -154,6 +154,39 @@ For screen layouts (not data flows), use these conventions:
154
154
 
155
155
  ---
156
156
 
157
+ ## Mermaid Diagrams
158
+
159
+ **Structure at rest is ASCII; behaviour over time is mermaid.** A file tree, an on-disk layout, a load-class split, a wireframe — draw with box characters, because the diagram *is* the thing's shape. A sequence of turns, a state machine, a branching flow — write mermaid, because a renderer tracks the arrows better than a reader tracks them across box art, and the source stays diffable when a step is inserted.
160
+
161
+ **Docs only**, on the same reasoning as ASCII: a diagram never enters an instruction file.
162
+
163
+ ### When to Use
164
+
165
+ | Diagram | Use for |
166
+ | --- | --- |
167
+ | `sequenceDiagram` | Who calls whom in what order — a session turn, a hand-off between components |
168
+ | `stateDiagram-v2` | Modes with transitions — a workflow, a record's lifecycle |
169
+ | `flowchart` | A process with branches, gates, or fan-out |
170
+ | `timeline` | A dated lineage, in records only — never in a truth document |
171
+
172
+ ### Rules
173
+
174
+ - **At most 7 participants and 15 interactions** per diagram. Past that, split it — an unreadable diagram is worse than the prose it replaced.
175
+ - **Labels under 8 words**, no trailing punctuation.
176
+ - **Declare a direction** on every flowchart (`flowchart TD` / `LR`) so the render is stable.
177
+ - **The diagram never carries a fact the prose omits.** It restates structure for a reader who scans; a reader who greps must still find everything in the text.
178
+ - **No styling directives** — no `style`, no `classDef`, no theme overrides. Rendering is the reader's; the source stays portable across GitHub, Obsidian, and a plain editor.
179
+
180
+ ```
181
+ flowchart LR
182
+ A[Plan approved] --> B{boundary}
183
+ B -->|BLOCK| C[Re-plan]
184
+ B -->|PASS| D[testing plan-gate]
185
+ D --> E[Implement]
186
+ ```
187
+
188
+ ---
189
+
157
190
  ## Code Examples
158
191
 
159
192
  ### Rules
@@ -344,7 +377,7 @@ Always end with:
344
377
  | Parameters | Type + purpose |
345
378
  | Returns | Type + shape |
346
379
  | State | Persistence points only |
347
- | Diagrams | ASCII for all flows — docs only, never instruction files |
380
+ | Diagrams | ASCII for structure at rest, mermaid for behaviour over time — docs only, never instruction files |
348
381
  | Code | Under 15 lines, contracts only |
349
382
  | Voice | Imperative/active |
350
383
  | Mechanics | Inline-code identifiers; bold-assertion bullets |
@@ -89,7 +89,7 @@ function lintFile(path, findings) {
89
89
  const isFolderIndex = /(^|\/)(readme)\.md$/i.test(path);
90
90
  if (scope === 'truth' && !isFolderIndex && /(^|\/)docs\/features\/[^/]+\.md$/.test(path)) {
91
91
  const fm = text.startsWith('---') ? text.slice(0, text.indexOf('\n---', 3)) : '';
92
- for (const key of ['owns:', 'keywords:', 'status:'])
92
+ for (const key of ['owns:', 'keywords:', 'status:', 'type:'])
93
93
  if (!fm.includes(key)) findings.push({ path, n: 1, severity: 'block', id: 'feature-frontmatter', message: `feature doc missing \`${key}\` frontmatter` });
94
94
  }
95
95
  }
@@ -16,6 +16,7 @@ One file per template. Every template is either a **truth document** (states cur
16
16
  | Testing strategy | [testing-strategy.md](testing-strategy.md) | Truth |
17
17
  | Behavioral checklist | [behavioral-checklist.md](behavioral-checklist.md) | Truth |
18
18
  | Operations procedure | [operations-procedure.md](operations-procedure.md) | Truth |
19
+ | Implementation map | [implementation-map.md](implementation-map.md) | Truth |
19
20
  | Bookkeeping ledger | [bookkeeping.md](bookkeeping.md) | Truth (work queue) |
20
21
  | Protected files | [protected-files.md](protected-files.md) | Truth |
21
22
  | Project root README | [root-readme.md](root-readme.md) | Truth |
@@ -16,6 +16,7 @@ Required: **Conventions**, at least one numbered behavior section, **Failure Rep
16
16
  - **Prefer exact literals** — the real prompt text, the real button label, the real count. A vague assertion passes a broken product.
17
17
  - **Assert persistence separately from display.** "It appeared on screen" and "it was written" are two claims; a surface that streams unsaved content satisfies the first while failing the second.
18
18
  - **State each section's trace.** `Pinned:` names the suite test that now guards it, or says `driven only` **with the reason** — an unmarked section is indistinguishable from an unexamined one.
19
+ - **State each section's `AC:` keys.** Where the repo carries a keyed acceptance-criteria source, each section names the keys it proves, so the chain from criterion to checklist to suite test survives the last hop; the gate reports coverage and backlogs uncovered keys. `none — not AC-mapped` is the honest line where no keyed source exists.
19
20
  - **A known-failing assertion stays written as the contract.** A checklist states what the feature promises; the *run* records the failure and the backlog carries the defect. Never soften an assertion to make a run go green.
20
21
 
21
22
  ```
@@ -44,6 +45,7 @@ Each step is an action; each **Assert** is the observable it must produce.
44
45
  - [ ] **Assert**: {the persistence claim, where the behavior writes something}
45
46
 
46
47
  > Pinned: `{tests/path/to/file.test.ts}` — or `driven only — {why no durable test is bought}`
48
+ > AC: {the acceptance-criteria keys this section proves} — or `none — not AC-mapped`
47
49
 
48
50
  ### 2. {The next behavior}
49
51
 
@@ -16,7 +16,7 @@ An entry is not a summary line. Its value concentrates in the fields a diff cann
16
16
  - **Iterations:** {N} · rejected: {what was rejected and why} | none
17
17
  - **Skills:** {skill}@{version} — survived | partially survived | overridden ({why, briefly}) | none used
18
18
  - **Change:** {added/changed X, which does Y, because Z — behavior first, mechanism second. Plan-linked entries also carry the verification gate's results here: rows discharged, suite outcome, anything blocked}
19
- - **Discovered:** {non-obvious fact learned by running the product — environment quirk, platform ceiling, seam behavior, dependency constraint} | none
19
+ - **Discovered:** {non-obvious fact learned by running the product — environment quirk, platform ceiling, seam behavior, dependency constraint} [source: {tag}] | none
20
20
  - **Class:** {one or more keywords — suggestive vocabulary, not a closed enum; see Field rules}
21
21
  - **Pin:** {enforced artifact written in this entry's commit: test / token / guardrail / schema} | none — candidate: {what should hold it}
22
22
  ```
@@ -39,7 +39,7 @@ An entry is not a summary line. Its value concentrates in the fields a diff cann
39
39
  - **`surface`** uses the repo's ownership vocabulary — the owning feature doc's topic or an `owns:` path stem — so entries are queryable per surface.
40
40
  - **`Ask` quotes taste verbatim.** The user's preference wording is the one thing no skill can infer and no rebuild recovers; the entry is its durable home.
41
41
  - **`Skills` records efficacy honestly** — survived / partially survived / overridden with a reason. These verdicts are the raw data the `retro` agent aggregates into skill-evolution findings; sanitizing them into success stories destroys the mechanism's value.
42
- - **`Discovered` holds what only running the product teaches.** If it was learned in use, it is recorded the same day — this field feeds the pin candidates.
42
+ - **`Discovered` holds what only running the product teaches.** If it was learned in use, it is recorded the same day — this field feeds the pin candidates. **Each finding carries a `source:` tag from a closed list** — `generator` (a generated doc was wrong) · `generator-conflict` (two generated docs disagreed) · `rules-conflict` (a generated doc contradicted standing rules) · `runtime-discovery` (only running revealed it) · `environment` (tooling or harness). Closed deliberately, unlike `Class:` — the consumer is the doc generator's readback grep, not a human. A finding in a repo with no generated docs is almost always `runtime-discovery` or `environment`.
43
43
  - **`Class` is a suggestive vocabulary, not a closed enum.** Tag with one or more keywords, preferring an existing term over a synonym so the `retro` agent can cluster; coin a new one when nothing fits — a recurring coinage is itself retro signal that the vocabulary (or the doc set) is missing a category. Suggested keywords: `ux` · `ui` · `visual` · `taste` · `layout` · `motion` · `interaction` · `copy` · `a11y` · `responsiveness` · `navigation` · `state` · `structure` · `architecture` · `seam` · `contract` · `schema` · `data` · `content` · `seed` · `performance` · `caching` · `resilience` · `error-handling` · `security` · `auth` · `observability` · `logging` · `testing` · `tooling` · `dependency` · `ops` · `deploy` · `env` · `ci` · `i18n` · `docs`.
44
44
  - **`Pin` closes the loop:** the enforced artifact written in this commit, or the named candidate for the `retro` agent to flag. A taste-verified behavior with `Pin: none` and no candidate is a template smell.
45
45
  - **Out-of-session entries** (the writing session did not do the work): fill what the diff and conversation evidence support; mark the rest `unknown — committed out-of-session`.
@@ -13,6 +13,7 @@ module: billing # optional — single bounded-c
13
13
  keywords: [invoice, pdf] # required — discovery terms
14
14
  status: active # required — active | draft | deprecated
15
15
  last-reviewed: YYYY-MM-DD # optional tooling key — stamped on substantive review/update; the maintenance agent ranks audit priority by it (metadata, never narrated in the body)
16
+ type: feature # required tooling key — genre stamp (generator-written or authored; enum = the template names), read by tooling (UI grouping, protected-file mapping); preserve on edits, never narrate
16
17
  ---
17
18
  ```
18
19
 
@@ -0,0 +1,42 @@
1
+ # Implementation Map (docs/operations/implementation-map.md)
2
+
3
+ Truth document. Use for: **the cold-start execution contract of a project being built from its documented design** — one per repo, at `docs/operations/implementation-map.md`. It is authored by the **executing session at build start**, never generated at design time: build order is derived from the repo's actual feature docs and their stated dependencies, and a design-time prediction of it goes stale the day reality disagrees. It lives in operations because it says *how to proceed*, not *what is true of the design*.
4
+
5
+ ## Writing the map
6
+
7
+ - **Derive waves from the feature docs** — their stated dependencies plus shared foundations (schema before routes, auth before anything it guards). A wave groups features that can build together; the order reflects foundations, not product importance.
8
+ - **A wave's done criterion is observable** — its plans' verification gates passed, the suite green, a named surface driveable. Never "feels complete".
9
+ - **Authority order names this repo's own docs, most specific wins.** A contradiction between documents is repaired in the owning truth doc *before* code — never reinterpreted while planning.
10
+ - **Plans are records, not an execution queue.** The map points at the active wave's dated plan; historical plans are read only when a truth doc cites them as provenance.
11
+ - **Update in place as waves complete** — the map states the current build position in present tense; the journey lives in the plans and changelog entries.
12
+
13
+ ```
14
+ # {Project} implementation map
15
+
16
+ ## Cold-start contract
17
+
18
+ {One paragraph: what a fresh session asked to "build this" is expected to do with this
19
+ repository — plan, gate, implement, verify, in what granularity — and the statement that it
20
+ needs nothing outside the repo.}
21
+
22
+ ## Authority order
23
+
24
+ When documents disagree:
25
+
26
+ 1. {The constitutional docs — guardrails, non-goals, PRD-class documents}
27
+ 2. The most specific feature doc under `docs/features/`
28
+ 3. {The structural truth docs — domain model, storage schema, API contracts}
29
+ 4. The dated plan for the active wave
30
+ 5. Owned source files as implementation evidence
31
+
32
+ ## Waves
33
+
34
+ | Wave | Features | Depends on | Done when |
35
+ | - | - | - | - |
36
+ | 1 | {docs/features/x.md, docs/features/y.md} | — | {observable criterion} |
37
+ | 2 | {…} | 1 | {…} |
38
+
39
+ ## Current position
40
+
41
+ {One line per wave: complete · in progress ([plan](../history/plans/YYYY-MM-DD-slug.md)) · pending.}
42
+ ```
@@ -47,14 +47,14 @@ Assign each behavior the **cheapest layer that can prove it**, then name the ora
47
47
  ```
48
48
  ## Test Obligations
49
49
 
50
- | # | Behavior (LLD promise) | Layer | Target file | Oracle | Fixtures / faults |
51
- | - | ---------------------- | ----- | ----------- | ------ | ----------------- |
50
+ | # | Behavior (LLD promise) | Layer | Target file | Oracle | Fixtures / faults | Outcome |
51
+ | - | ---------------------- | ----- | ----------- | ------ | ----------------- | ------- |
52
52
 
53
53
  Coverage: {N} behaviors · {N} integration · {N} component · {N} unit · {N} e2e ({N} extend, {N} new)
54
54
  Completion checklist: docs/tests/{feature}.md (derived at the gate, not now)
55
55
  ```
56
56
 
57
- A behavior with no obligation is stated too, with the reason — "none — compiler-guarded" reads as *checked*; an empty cell reads as *not analyzed*. A short manifest is valid — do not pad.
57
+ A behavior with no obligation is stated too, with the reason — "none — compiler-guarded" reads as *checked*; an empty cell reads as *not analyzed*. A short manifest is valid — do not pad. **`Outcome` is written empty here — it is gate mode's cell**, and the completion walls read it: a plan cannot reach `Status: Complete` while any row's Outcome is empty.
58
58
  6. **Record the write in the plan's `Gates:` line**, rewriting only your segment to exactly `Test-plan: written YYYY-MM-DD (N rows)` — the compliance hook greps this exact form. Add the line if the plan predates the format.
59
59
 
60
60
  ---
@@ -162,5 +162,5 @@ Runs only when the user triggers the verification gate (constraints — precondi
162
162
 
163
163
  A driven check is evidence, not an artifact — it proves the change works today and leaves nothing behind; that is why this rung exists.
164
164
  4. **Run the affected suite**, plus the project's e2e smoke gate where one is wired.
165
- 5. **Discharge every ledger row into exactly one outcome**: **satisfied** (test green, or driven check passed), **owes nothing** (with the reason — compiler-guarded, covered elsewhere), or **blocked** (missing infrastructure named, row converted to a backlog item). A row left unexamined blocks the gate.
166
- 6. **Report results honestly, including failures**; record which test now guards each checklist section (`Pinned:` traces). Completion steps — freezing the plan, the changelog entry — are the root policy file's, not yours.
165
+ 5. **Discharge every ledger row into exactly one outcome**: **satisfied** (test green, or driven check passed), **owes nothing** (with the reason — compiler-guarded, covered elsewhere), or **blocked** (missing infrastructure named, row converted to a backlog item). **Open by re-reading the ledger from the plan file on disk, never from conversation memory** — a context-held copy is how rows get missed. **Write each row's outcome into its `Outcome` cell in the plan file** — exactly `satisfied`, `owes nothing — {reason}`, or `blocked → backlog` — because the Stop wall and CI read that cell, and a row left unexamined blocks completion.
166
+ 6. **Report results honestly, including failures**; record which test now guards each checklist section (`Pinned:` traces) and each section's `AC:` line where the repo carries a keyed acceptance-criteria source, closing with the coverage count — `AC coverage: {n}/{m}; uncovered: {keys} → backlog`. Completion steps — freezing the plan, the changelog entry — are the root policy file's, not yours.
@@ -62,6 +62,7 @@ For each `owns:` glob across the feature docs:
62
62
 
63
63
  - Verify the glob matches existing files. Report globs matching nothing, deleted paths, and orphaned docs.
64
64
  - Flag **equal-specificity overlaps** — two feature docs whose `owns:` globs claim the same paths at the same specificity (an ambiguous owner). Surface as a question.
65
+ - **Stamp `type:` where missing.** Every doc carries a `type:` genre key (enum = the `documentation` skill's template names); docs predating the key are stamped mechanically — the genre is readable from placement and shape (a `docs/features/` doc is `type: feature`, an AD is `type: architecture-decision`, and so on). Stamping is remediation, not a question; an ambiguous genre is backlogged instead.
65
66
 
66
67
  For each code symbol or path a doc explicitly **names** (function/export/route/type):
67
68