@nurix/etna 0.19.43 → 0.19.45

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 (60) hide show
  1. package/bin/etna.js +33 -6
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/apollo/agents/bookkeeper.md +27 -0
  4. package/data/harnesses/apollo/agents/maintenance.md +3 -1
  5. package/data/harnesses/apollo/harness.json +2 -2
  6. package/data/harnesses/apollo/hooks/awareness.mjs +370 -0
  7. package/data/harnesses/apollo/hooks/check-bookkeeping-clean.mjs +19 -0
  8. package/data/harnesses/apollo/hooks/check-changelog-presence.mjs +48 -0
  9. package/data/harnesses/apollo/hooks/check-completion-integrity.mjs +68 -0
  10. package/data/harnesses/apollo/hooks/check-ownership.mjs +22 -0
  11. package/data/harnesses/apollo/hooks/compliance.mjs +353 -0
  12. package/data/harnesses/apollo/hooks/hooks.json +16 -10
  13. package/data/harnesses/apollo/hooks/lib/frozen.mjs +58 -0
  14. package/data/harnesses/apollo/hooks/lib/ownership.mjs +223 -0
  15. package/data/harnesses/apollo/hooks/lib/state.mjs +156 -0
  16. package/data/harnesses/apollo/skills/boundary/SKILL.md +3 -1
  17. package/data/harnesses/apollo/skills/documentation/SKILL.md +2 -0
  18. package/data/harnesses/apollo/skills/documentation/templates/Readme.md +2 -0
  19. package/data/harnesses/apollo/skills/documentation/templates/bookkeeping.md +18 -0
  20. package/data/harnesses/apollo/skills/documentation/templates/journal.md +13 -0
  21. package/data/harnesses/apollo/skills/documentation/templates/plan.md +3 -0
  22. package/data/harnesses/apollo/skills/test-plan/SKILL.md +2 -0
  23. package/data/harnesses/code/agents/bookkeeper.md +27 -0
  24. package/data/harnesses/code/agents/maintenance.md +3 -1
  25. package/data/harnesses/code/harness.json +2 -2
  26. package/data/harnesses/code/hooks/awareness.mjs +370 -0
  27. package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +19 -0
  28. package/data/harnesses/code/hooks/check-changelog-presence.mjs +48 -0
  29. package/data/harnesses/code/hooks/check-completion-integrity.mjs +68 -0
  30. package/data/harnesses/code/hooks/check-ownership.mjs +22 -0
  31. package/data/harnesses/code/hooks/compliance.mjs +353 -0
  32. package/data/harnesses/code/hooks/hooks.json +16 -10
  33. package/data/harnesses/code/hooks/lib/frozen.mjs +58 -0
  34. package/data/harnesses/code/hooks/lib/ownership.mjs +223 -0
  35. package/data/harnesses/code/hooks/lib/state.mjs +156 -0
  36. package/data/harnesses/code/skills/boundary/SKILL.md +3 -1
  37. package/data/harnesses/code/skills/documentation/SKILL.md +2 -0
  38. package/data/harnesses/code/skills/documentation/templates/Readme.md +2 -0
  39. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +18 -0
  40. package/data/harnesses/code/skills/documentation/templates/journal.md +13 -0
  41. package/data/harnesses/code/skills/documentation/templates/plan.md +3 -0
  42. package/data/harnesses/code/skills/test-plan/SKILL.md +2 -0
  43. package/data/harnesses/delivery/agents/maintenance.md +3 -1
  44. package/data/harnesses/delivery/harness.json +2 -2
  45. package/data/harnesses/delivery/skills/documentation/SKILL.md +2 -0
  46. package/data/harnesses/delivery/skills/documentation/templates/Readme.md +2 -0
  47. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +18 -0
  48. package/data/harnesses/delivery/skills/documentation/templates/journal.md +13 -0
  49. package/data/harnesses/delivery/skills/documentation/templates/plan.md +3 -0
  50. package/data/harnesses/etna/agents/maintenance.md +3 -1
  51. package/data/harnesses/etna/harness.json +2 -2
  52. package/data/harnesses/etna/skills/boundary/SKILL.md +3 -1
  53. package/data/harnesses/etna/skills/documentation/SKILL.md +2 -0
  54. package/data/harnesses/etna/skills/documentation/templates/Readme.md +2 -0
  55. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +18 -0
  56. package/data/harnesses/etna/skills/documentation/templates/journal.md +13 -0
  57. package/data/harnesses/etna/skills/documentation/templates/plan.md +3 -0
  58. package/package.json +2 -2
  59. package/data/harnesses/apollo/hooks/generate-ownership-index.mjs +0 -148
  60. package/data/harnesses/code/hooks/generate-ownership-index.mjs +0 -148
@@ -0,0 +1,223 @@
1
+ // harness/code/hooks/lib/ownership.mjs — frontmatter scan + owns:-glob resolution, shared by the
2
+ // hook entrypoints (awareness, compliance) and the CI checks so gates and pipeline cannot disagree.
3
+ // Everything here is read-only over docs/features/ and the source tree; no network, no deps.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // Read all of stdin synchronously; returns "" if nothing is piped.
9
+ export function readStdin() {
10
+ try {
11
+ return fs.readFileSync(0, "utf8");
12
+ } catch (e) {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
18
+ export function resolveProjectDir(payload) {
19
+ return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
20
+ }
21
+
22
+ // Recursively collect every .md file under dir (returns [] if the dir is absent).
23
+ export function listMarkdown(dir) {
24
+ const out = [];
25
+ let entries;
26
+ try {
27
+ entries = fs.readdirSync(dir, { withFileTypes: true });
28
+ } catch (e) {
29
+ return out;
30
+ }
31
+ for (const entry of entries) {
32
+ const full = path.join(dir, entry.name);
33
+ if (entry.isDirectory()) out.push(...listMarkdown(full));
34
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // Pull the leading `--- … ---` frontmatter block; "" if the file has none.
40
+ export function frontmatter(text) {
41
+ const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
42
+ return m ? m[1] : "";
43
+ }
44
+
45
+ // Trim surrounding whitespace, then strip one layer of matching quotes.
46
+ export function unquote(s) {
47
+ return String(s).trim().replace(/^["']|["']$/g, "").trim();
48
+ }
49
+
50
+ // One YAML list/array item → clean value: a quoted item ends at its closing quote
51
+ // (anything after — trailing comments — drops); an unquoted item ends at ` #`.
52
+ export function cleanItem(s) {
53
+ const t = String(s).trim();
54
+ const q = t[0];
55
+ if (q === '"' || q === "'") {
56
+ const end = t.indexOf(q, 1);
57
+ if (end > 0) return t.slice(1, end).trim();
58
+ }
59
+ const hash = t.search(/\s#/);
60
+ return unquote(hash >= 0 ? t.slice(0, hash) : t);
61
+ }
62
+
63
+ // `[a, b] # comment` → items; content ends at the closing bracket, not the line end.
64
+ function parseInlineArray(raw) {
65
+ const end = raw.indexOf("]");
66
+ const body = (end >= 0 ? raw.slice(0, end) : raw).replace(/^\[/, "");
67
+ return body.split(",").map((s) => cleanItem(s)).filter(Boolean);
68
+ }
69
+
70
+ // Read one frontmatter key: scalar (`k: v`), inline array (`k: [a, b]`), or a following `- item` block.
71
+ // Returns a string for scalars, an array for lists, or undefined when the key is absent.
72
+ export function field(block, key) {
73
+ const lines = block.split(/\r?\n/);
74
+ const head = new RegExp(`^${key}\\s*:\\s*(.*)$`);
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const m = head.exec(lines[i]);
77
+ if (!m) continue;
78
+ const raw = m[1].trim();
79
+ if (raw === "") {
80
+ // A bare key may continue as an indented inline array on the next line, or a `- item` block.
81
+ const next = (lines[i + 1] || "").trim();
82
+ if (next.startsWith("[")) {
83
+ return parseInlineArray(next);
84
+ }
85
+ const items = [];
86
+ for (let j = i + 1; j < lines.length; j++) {
87
+ const li = /^\s*-\s+(.*)$/.exec(lines[j]);
88
+ if (!li) break;
89
+ items.push(cleanItem(li[1]));
90
+ }
91
+ return items;
92
+ }
93
+ if (raw.startsWith("[")) {
94
+ return parseInlineArray(raw);
95
+ }
96
+ return unquote(raw);
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ // Coerce a field that may be scalar/array/undefined into a clean string array.
102
+ export function asList(v) {
103
+ if (v == null) return [];
104
+ return (Array.isArray(v) ? v : [v]).map((s) => String(s).trim()).filter(Boolean);
105
+ }
106
+
107
+ // Scan docs/features/**/*.md and return one entry per feature doc that declares owns:.
108
+ // Shape: { doc (repo-relative, /-separated), owns[], module, keywords[], status }.
109
+ export function scanFeatureDocs(projectDir) {
110
+ const featuresDir = path.join(projectDir, "docs", "features");
111
+ const files = listMarkdown(featuresDir);
112
+ const docs = [];
113
+ for (const file of files) {
114
+ let text;
115
+ try {
116
+ text = fs.readFileSync(file, "utf8");
117
+ } catch (e) {
118
+ continue;
119
+ }
120
+ const block = frontmatter(text);
121
+ if (!block) continue;
122
+ const owns = asList(field(block, "owns"));
123
+ if (owns.length === 0) continue;
124
+ docs.push({
125
+ doc: path.relative(projectDir, file).split(path.sep).join("/"),
126
+ owns,
127
+ module: (field(block, "module") || "—").toString(),
128
+ keywords: asList(field(block, "keywords")),
129
+ status: (field(block, "status") || "active").toString().toLowerCase(),
130
+ });
131
+ }
132
+ return docs;
133
+ }
134
+
135
+ // Build the ownership index text from every feature doc's frontmatter.
136
+ export function buildIndex(projectDir) {
137
+ const docs = scanFeatureDocs(projectDir);
138
+ const lines = [];
139
+ let deprecated = 0;
140
+ for (const entry of docs) {
141
+ if (entry.status === "deprecated") {
142
+ deprecated += 1;
143
+ continue; // keep deprecated docs out of the routing lines
144
+ }
145
+ const kw = entry.keywords.length ? `[${entry.keywords.join(", ")}]` : "[]";
146
+ for (const glob of entry.owns) {
147
+ lines.push(`${glob} → ${entry.doc} · ${entry.module} · ${kw} · ${entry.status}`);
148
+ }
149
+ }
150
+ if (lines.length === 0) return "";
151
+ lines.sort();
152
+ const header =
153
+ "# Ownership index (generated) — code-path glob → owning feature doc · module · keywords · status\n" +
154
+ "# Match the file or topic you're about to touch, then read the owning doc before changing it.\n";
155
+ const footer = deprecated > 0 ? `\n# (${deprecated} deprecated feature doc(s) omitted)` : "";
156
+ return header + lines.join("\n") + footer;
157
+ }
158
+
159
+ // Compile one owns:-style glob (`**` any depth, `*` within a segment) to a RegExp
160
+ // over /-separated repo-relative paths. `a/**` also matches `a` itself.
161
+ export function globToRegExp(glob) {
162
+ const g = glob.replace(/\\/g, "/").replace(/\/+$/, "");
163
+ const escaped = g.replace(/[\\^$.|?+()[\]{}]/g, "\\$&");
164
+ const re = escaped
165
+ .replace(/\/\*\*$/, "\0TAIL\0") // `x/**` → x plus optionally anything below it
166
+ .replace(/\*\*\//g, "\0LEAD\0") // `**/x` → any (possibly empty) directory prefix
167
+ .replace(/\*\*/g, "\0ANY\0")
168
+ .replace(/\*/g, "[^/]*")
169
+ .replace(/\0TAIL\0/g, "(?:/.*)?")
170
+ .replace(/\0LEAD\0/g, "(?:.*/)?")
171
+ .replace(/\0ANY\0/g, ".*");
172
+ return new RegExp(`^${re}$`);
173
+ }
174
+
175
+ // Feature docs owning the given repo-relative path (deprecated docs excluded).
176
+ export function owningDocs(projectDir, relPath) {
177
+ const rel = relPath.split(path.sep).join("/");
178
+ const out = [];
179
+ for (const entry of scanFeatureDocs(projectDir)) {
180
+ if (entry.status === "deprecated") continue;
181
+ if (entry.owns.some((g) => globToRegExp(g).test(rel))) out.push(entry);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".claude", ".codex"]);
187
+
188
+ // Walk the repo once and return every tracked-ish file path (repo-relative, /-separated).
189
+ // Used to detect dead globs; heavy dirs are skipped, depth is bounded by the tree itself.
190
+ export function listRepoFiles(projectDir) {
191
+ const out = [];
192
+ function walk(dir) {
193
+ let entries;
194
+ try {
195
+ entries = fs.readdirSync(dir, { withFileTypes: true });
196
+ } catch (e) {
197
+ return;
198
+ }
199
+ for (const entry of entries) {
200
+ if (SKIP_DIRS.has(entry.name)) continue;
201
+ const full = path.join(dir, entry.name);
202
+ if (entry.isDirectory()) walk(full);
203
+ else if (entry.isFile()) out.push(path.relative(projectDir, full).split(path.sep).join("/"));
204
+ }
205
+ }
206
+ walk(projectDir);
207
+ return out;
208
+ }
209
+
210
+ // Globs across all feature docs that match zero files on disk.
211
+ // Returns [{ glob, doc }]; pass a precomputed file list to amortize the walk.
212
+ export function deadGlobs(projectDir, repoFiles) {
213
+ const files = repoFiles || listRepoFiles(projectDir);
214
+ const dead = [];
215
+ for (const entry of scanFeatureDocs(projectDir)) {
216
+ if (entry.status === "deprecated") continue;
217
+ for (const glob of entry.owns) {
218
+ const re = globToRegExp(glob);
219
+ if (!files.some((f) => re.test(f))) dead.push({ glob, doc: entry.doc });
220
+ }
221
+ }
222
+ return dead;
223
+ }
@@ -0,0 +1,156 @@
1
+ // harness/code/hooks/lib/state.mjs — the bookkeeping queue (docs/operations/bookkeeping.md) and the
2
+ // machine-local state dir (.claude/state/). The row grammar is the contract between the awareness
3
+ // detector (writer), the bookkeeper agent (drain), the compliance gates (walls), and CI (backstop):
4
+ // - auto: {YYYY-MM-DD} · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
5
+ // - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
6
+ // - needs-session: {YYYY-MM-DD} · {what could not be grounded} → main session resolves | refs={ts}
7
+ // - waived: {YYYY-MM-DD} · {what is deferred} · reason: {why} → expires={YYYY-MM-DD}
8
+ // Unresolved = auto + note + needs-session. waived: rows are resolved history until expires= passes.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
14
+
15
+ export const QUEUE_HEADER = `# Bookkeeping
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.
18
+ `;
19
+
20
+ export function queueFile(projectDir) {
21
+ return path.join(projectDir, ...QUEUE_PATH);
22
+ }
23
+
24
+ const ROW_RE = /^-\s+(auto|note|needs-session|waived):\s+(.*)$/;
25
+
26
+ // Parse the queue text into rows: { kind, date, body, raw }. Non-row lines are ignored.
27
+ export function parseQueue(text) {
28
+ const rows = [];
29
+ for (const line of String(text || "").split(/\r?\n/)) {
30
+ const m = ROW_RE.exec(line.trim());
31
+ if (!m) continue;
32
+ const body = m[2].trim();
33
+ const date = (/^(\d{4}-\d{2}-\d{2})/.exec(body) || [])[1] || "";
34
+ rows.push({ kind: m[1], date, body, raw: line });
35
+ }
36
+ return rows;
37
+ }
38
+
39
+ export function readQueue(projectDir) {
40
+ try {
41
+ return parseQueue(fs.readFileSync(queueFile(projectDir), "utf8"));
42
+ } catch (e) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ // Counts by kind; `unresolved` is the number the walls act on.
48
+ export function countRows(rows) {
49
+ const c = { auto: 0, note: 0, needsSession: 0, waived: 0 };
50
+ for (const row of rows) {
51
+ if (row.kind === "auto") c.auto += 1;
52
+ else if (row.kind === "note") c.note += 1;
53
+ else if (row.kind === "needs-session") c.needsSession += 1;
54
+ else if (row.kind === "waived") c.waived += 1;
55
+ }
56
+ c.unresolved = c.auto + c.note + c.needsSession;
57
+ return c;
58
+ }
59
+
60
+ // Format one auto: row per the grammar; sid/tp/ts come from the hook payload.
61
+ export function formatAutoRow({ date, kind, description, action, sessionId, transcriptPath, ts }) {
62
+ return `- auto: ${date} · ${kind} · ${description} → ${action} | sid=${sessionId || "-"} tp=${transcriptPath || "-"} ts=${ts || "-"}`;
63
+ }
64
+
65
+ // Append rows to the queue, creating the file with its genre header when absent.
66
+ // Dedupes on the row text before the `| sid=` tail, so a re-fired detector never stacks duplicates.
67
+ export function appendRows(projectDir, rowLines) {
68
+ if (!rowLines || rowLines.length === 0) return 0;
69
+ const file = queueFile(projectDir);
70
+ let text = "";
71
+ try {
72
+ text = fs.readFileSync(file, "utf8");
73
+ } catch (e) {
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
75
+ text = QUEUE_HEADER + "\n";
76
+ }
77
+ const key = (line) => line.split("| sid=")[0].trim();
78
+ const existing = new Set(parseQueue(text).map((r) => key(r.raw)));
79
+ const fresh = rowLines.filter((line) => !existing.has(key(line)));
80
+ if (fresh.length === 0) return 0;
81
+ if (!text.endsWith("\n")) text += "\n";
82
+ fs.writeFileSync(file, text + fresh.join("\n") + "\n");
83
+ return fresh.length;
84
+ }
85
+
86
+ // Delete queue rows matching the predicate (used by the detector to auto-resolve
87
+ // rows whose finding the session has since addressed). Returns the count removed.
88
+ export function resolveRows(projectDir, predicate) {
89
+ const file = queueFile(projectDir);
90
+ let text;
91
+ try {
92
+ text = fs.readFileSync(file, "utf8");
93
+ } catch (e) {
94
+ return 0;
95
+ }
96
+ let removed = 0;
97
+ const kept = text.split(/\r?\n/).filter((line) => {
98
+ const m = ROW_RE.exec(line.trim());
99
+ if (!m) return true;
100
+ const row = { kind: m[1], body: m[2].trim(), raw: line };
101
+ if (predicate(row)) {
102
+ removed += 1;
103
+ return false;
104
+ }
105
+ return true;
106
+ });
107
+ if (removed > 0) fs.writeFileSync(file, kept.join("\n"));
108
+ return removed;
109
+ }
110
+
111
+ // Unexpired waived rows for a finding kind (the dead-glob allowlist CI honors).
112
+ // A waived row covers a glob when its body names it verbatim.
113
+ export function unexpiredWaivers(rows, kind, today) {
114
+ return rows.filter((row) => {
115
+ if (row.kind !== "waived") return false;
116
+ if (kind && !row.body.includes(kind)) return false;
117
+ const expires = (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1];
118
+ if (!expires) return false;
119
+ return expires >= today;
120
+ });
121
+ }
122
+
123
+ // Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
124
+ // so consumers never see machine-local state as untracked noise. Returns the dir.
125
+ export function stateDir(projectDir) {
126
+ const dir = path.join(projectDir, ".claude", "state");
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ const ignore = path.join(dir, ".gitignore");
129
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
130
+ return dir;
131
+ }
132
+
133
+ // Session-scoped ask memory: an `ask` gate fires once per (session, class); after the user has
134
+ // answered it once, repeats in the same session pass silently (the runtime's own permission
135
+ // prompts still apply). Records live in .claude/state/asked.tsv — machine-local, self-ignored.
136
+ export function askedThisSession(projectDir, sessionId, key) {
137
+ if (!sessionId) return false;
138
+ try {
139
+ const text = fs.readFileSync(path.join(projectDir, ".claude", "state", "asked.tsv"), "utf8");
140
+ return text.split("\n").includes(`${sessionId}\t${key}`);
141
+ } catch (e) {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ export function recordAsked(projectDir, sessionId, key) {
147
+ if (!sessionId) return;
148
+ fs.appendFileSync(path.join(stateDir(projectDir), "asked.tsv"), `${sessionId}\t${key}\n`);
149
+ }
150
+
151
+ // Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
152
+ export function recordTouched(projectDir, relPath, docs) {
153
+ const dir = stateDir(projectDir);
154
+ const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
155
+ fs.appendFileSync(path.join(dir, "touched.tsv"), line);
156
+ }
@@ -61,9 +61,11 @@ The packs are a floor, not a fence: report any contract that passes the ships-gr
61
61
 
62
62
  **BLOCK** = any CRITICAL. HIGH findings yield **PASS WITH AMENDMENTS** — fold the amendments into the plan before coding. Order amendments sequencing-first: many violations dissolve by reordering steps (expand-then-contract, migrate-before-code) rather than changing what ships — prefer that fix, drawn from the entry's safe-change sequence.
63
63
 
64
+ 6. **Record the verdict in the plan's `Gates:` line.** Rewrite only your own segment, to exactly `Boundary: passed YYYY-MM-DD` (PASS, or PASS WITH AMENDMENTS once the amendments are folded) or `Boundary: blocked YYYY-MM-DD` (BLOCK). The compliance hook greps these exact forms — implementation edits stay blocked while the segment reads `pending`. A plan with no `Gates:` line predates the format; add the line rather than skipping the write-back.
65
+
64
66
  ## Rules
65
67
 
66
- - **Read-only.** You never edit the plan, the code, or any doc. The user folds your amendments into the plan.
68
+ - **Read-only, one exception.** You never edit the plan's content, the code, or any doc — the user folds your amendments into the plan. The single write you own is the plan's `Gates:` line (step 6), your segment only.
67
69
  - **Verify in source.** Never flag from plan text alone — read both sides of every finding and cite them: code as `file:line`, doctrine as the doc path.
68
70
  - **No other side, no violation.** An intersection with no traced consumer and no doctrine behind it isn't a violation — it's a **new edge**: report it in its own section with the hardening its mechanism prescribes (version the cache key now, make the enum append-only now), so the fragile edge is born safe.
69
71
  - **A clean report is valid.** Zero violations means say so — the coverage table is the evidence it was checked, not skipped. Don't pad findings.
@@ -73,6 +73,8 @@ 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
+ | 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
+ | 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 |
76
78
  | One-shot migration/upgrade | [`templates/migration-runbook.md`](templates/migration-runbook.md) (record) |
77
79
  | Add to protected files | [`templates/protected-files.md`](templates/protected-files.md) |
78
80
  | Create/update root README | [`templates/root-readme.md`](templates/root-readme.md) |
@@ -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
+ | Bookkeeping ledger | [bookkeeping.md](bookkeeping.md) | Truth (work queue) |
19
20
  | Protected files | [protected-files.md](protected-files.md) | Truth |
20
21
  | Project root README | [root-readme.md](root-readme.md) | Truth |
21
22
  | docs/README.md (doc index) | [docs-readme.md](docs-readme.md) | Truth |
@@ -23,6 +24,7 @@ One file per template. Every template is either a **truth document** (states cur
23
24
  | Plan | [plan.md](plan.md) | Record |
24
25
  | Changelog entry | [changelog-entry.md](changelog-entry.md) | Record |
25
26
  | Migration runbook | [migration-runbook.md](migration-runbook.md) | Record |
27
+ | Journal | [journal.md](journal.md) | Record (append-only) |
26
28
  | CHANGELOG master index | [changelog-index.md](changelog-index.md) | Record index (append-only) |
27
29
  | Backlog | [backlog.md](backlog.md) | Record (append-only) |
28
30
  | Memory (main agent) | [memory-main.md](memory-main.md) | Record (append-only ledger) |
@@ -0,0 +1,18 @@
1
+ # Bookkeeping ledger (docs/operations/bookkeeping.md)
2
+
3
+ Truth document with an unusual lifecycle: a **work queue**, not an archive. Rows are appended mechanically by the awareness hook (`auto:`) and deliberately by working sessions (`note:`), drained by the `bookkeeper` agent, and walled at Stop and commit — a commit is possible only when every row is resolved (deleted) or converted to `waived:`. What survives in git — `waived:` and `needs-session:` rows — is exactly what a PR reviewer should see.
4
+
5
+ The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `waived:` rows whose finding kind is `dead-glob` double as the dead-glob allowlist.
6
+
7
+ ```
8
+ # Bookkeeping
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.
11
+
12
+ - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
13
+ - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
14
+ - needs-session: YYYY-MM-DD · {what the bookkeeper could not ground} → main session resolves | refs={original row ts}
15
+ - waived: YYYY-MM-DD · {what is deferred} · reason: {why} → expires=YYYY-MM-DD
16
+ ```
17
+
18
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `boundary-scan-suggested` · `doc-placement`.
@@ -0,0 +1,13 @@
1
+ # Journal (docs/history/journal.md)
2
+
3
+ Record — append-only. Use for: durable mid-execution knowledge — a fact discovered while building that some owning doc will eventually carry, captured the moment it exists so it survives the session. A row is resolved by landing in its owning doc: the row is deleted and the changelog entry cites it. The file stays short by construction; a journal that only grows is a queue nobody drains.
4
+
5
+ One line per fact. Never narrative, never progress notes (progress lives in the plan), never user preferences (those go to the runtime's memory store).
6
+
7
+ ```
8
+ # Journal
9
+
10
+ Append-only capture of mid-execution knowledge — one line per fact, written the moment it exists, resolved by landing in its owning doc (delete the row, cite it in the changelog entry). Format: `YYYY-MM-DD · <area> · <one-line fact> → lands-in: <owning doc | backlog | memory>`
11
+
12
+ - YYYY-MM-DD · {area} · {one-line fact} → lands-in: {owning doc | backlog | memory}
13
+ ```
@@ -4,10 +4,13 @@ Record. Use for: every implementation plan. Plans are created here directly with
4
4
 
5
5
  The `boundary` skill parses the **Proposed Changes** sections to check boundary impact before execution, and the `test-plan` skill writes a **Test Obligations** section into the plan at the same gate. Keep that structure stable — section headings and bullet shape are load-bearing for the parsers, and the Test Obligations table is the ledger the verification gate discharges before the plan may reach `Status: Complete`.
6
6
 
7
+ The **`Gates:`** line is machine-read: the compliance hook greps exactly the forms below, and implementation edits are blocked while a segment reads `pending`. Each gate skill rewrites only its own segment when it runs — `boundary` to `Boundary: passed YYYY-MM-DD` or `Boundary: blocked YYYY-MM-DD`; `test-plan` to `Test-plan: written YYYY-MM-DD (N rows)`. Never hand-edit the line to skip a gate.
8
+
7
9
  ```
8
10
  # {Feature Name}
9
11
 
10
12
  **Status:** Draft | In Progress | Complete | Abandoned
13
+ **Gates:** Boundary: pending · Test-plan: pending
11
14
  **Spec:** [docs/features/{feature}.md](../../features/{feature}.md)
12
15
  **Started:** YYYY-MM-DD
13
16
  **Completed:** YYYY-MM-DD <!-- fill when status flips to Complete -->
@@ -73,6 +73,8 @@ An e2e obligation (a scenario in the project's e2e tree per `testing.md`) is ear
73
73
 
74
74
  A behavior with no obligation is stated too, with the reason (compiler-guarded, covered by an existing test) — an empty cell reads as "not yet analyzed"; an explicit "none — compiler-guarded" reads as "checked."
75
75
 
76
+ 6. **Record the write in the plan's `Gates:` line.** Rewrite only your own segment, to exactly `Test-plan: written YYYY-MM-DD (N rows)` where N is the ledger's row count. The compliance hook greps this exact form. A plan with no `Gates:` line predates the format; add the line rather than skipping the write-back.
77
+
76
78
  ## Rules
77
79
 
78
80
  - **Manifest only.** You write the Test Obligations section into the plan and a chat summary. You write **no** test files and **no** `docs/tests/` checklist — those are verification-gate-time and Completion-time, owned by others.
@@ -0,0 +1,27 @@
1
+ ---
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".
4
+ tools: Read, Glob, Grep, Edit, Write, Bash, Skill
5
+ model: inherit
6
+ ---
7
+
8
+ # Bookkeeper Agent
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.
11
+
12
+ ## Source-of-truth order — the repo first, the transcript last
13
+
14
+ Ground every edit in this ladder, in order. Never invent content; a fact you cannot ground is a fact you do not write.
15
+
16
+ 1. **`auto:` rows are recomputable from code + docs on any machine — fix directly.** A `dead-glob` row: read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter. A `header-missing` row: add the file-path header comment in the file's native comment syntax. A `doc-lint` row: fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate. An `index-row-missing` row: add the doc's row to `docs/README.md`. A `doc-implicated` row: read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it.
17
+ 2. **`note:` rows — expand strictly from the stated context.** The one-line context after the area is everything the session chose to hand you; land it in the destination the row names (`lands-in:` an owning doc, the backlog, or memory), written in that document's genre.
18
+ 3. **A thin note, same machine only** — read a timestamp-anchored window of the transcript JSONL at the row's `tp=` path around its `ts=` stamp (never the whole file). The stamp is best-effort and dead on other clones — expected; treat an unreadable or missing transcript as "no window".
19
+ 4. **Still ungrounded** — replace the row with a `needs-session:` row (`- needs-session: {date} · {what you could not ground} → main session resolves | refs={original ts}`) and stop working that row.
20
+
21
+ ## Rules
22
+
23
+ - **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
24
+ - **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
25
+ - **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
26
+ - **Never waive on your own judgment** — waiving is the main session's (or the user's) call; your outcomes are fixed, landed, or `needs-session:`.
27
+ - **Report by row**: one line each — the row, the outcome (fixed / landed in {doc} / needs-session), and the files touched. The main session relays this; keep it terse.
@@ -115,7 +115,7 @@ For unowned code paths:
115
115
 
116
116
  ### Step 6: Report & update tracking
117
117
 
118
- 1. Update `docs/structure.md` Health Log with today's date and summary — **append to the end**, never date-sorted into the middle. Rows are positional: one may say "the row above is…", and an inserted row silently breaks that reference.
118
+ 1. Update `docs/structure.md` Health Log with today's date and summary — **append to the end**, never date-sorted into the middle. Rows are positional: one may say "the row above is…", and an inserted row silently breaks that reference. A Health Log entry is a **generated row, not a narrative essay**: one line — date · counts (stale docs fixed, dead globs, unowned paths, backlog items created) · links to anything that needs a human.
119
119
  2. Update `docs/memory/maintenance.md` with last review date, stale docs fixed, unowned paths, backlog items created.
120
120
  3. Report findings: stale docs, broken ownership, unowned code, architecture drift, backlog health.
121
121
 
@@ -137,6 +137,7 @@ docs/
137
137
  ├── features/ # Feature specs — the only first-class doc unit (flat; ownership in each doc's `owns:` frontmatter)
138
138
  │ └── {group}/ # browse-only subfolder for grouping; NEVER a module; may carry a Readme.md
139
139
  ├── operations/ # Setup, development, deployment, CI/CD
140
+ │ └── bookkeeping.md # the shared doc-sync work queue (hook-appended, bookkeeper-drained, walled at commit) — author-created, never scaffolded; treat rows as live work, never as drift
140
141
  ├── research/ # In-progress brainstorm sessions ({topic}-brainstorm/; legacy -brainstorming/) + standing research; Readme.md is the index
141
142
  │ └── {topic}-brainstorm/ # created by the `brainstorm` skill; moves to history/brainstorms/ on a passed harvest
142
143
  ├── history/
@@ -144,6 +145,7 @@ docs/
144
145
  │ ├── changelog/ # Individual entries (YYYY-MM-DD-{three-words}.md; written at commit time)
145
146
  │ ├── plans/ # Implementation plans (Status field required)
146
147
  │ ├── migrations/ # One-shot migration/upgrade runbooks (YYYY-MM-DD-{slug}.md; outcome-stamped then frozen)
148
+ │ ├── journal.md # Append-only mid-execution knowledge capture (one line per fact; resolves by landing in its owning doc) — author-created, never scaffolded
147
149
  │ ├── brainstorms/ # Harvested brainstorm sessions ({topic}/) — user decides kept (committed) or deleted; Readme.md is the index
148
150
  │ ├── research/ # Deep-research records ({topic}/ — README.md synthesis + findings.md + sources.md); frozen, cited, owns no code
149
151
  │ └── backlog.md ✱ # Tracked work items by priority (P0-P3)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.5.9",
4
+ "version": "3.5.11",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "b48a9de4fe22"
11
+ "digest": "132494301fd0"
12
12
  }