@nurix/etna 0.19.44 → 0.19.46
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.
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/agents/bookkeeper.md +27 -0
- package/data/harnesses/apollo/harness.json +2 -2
- package/data/harnesses/apollo/hooks/awareness.mjs +380 -0
- package/data/harnesses/apollo/hooks/check-bookkeeping-clean.mjs +19 -0
- package/data/harnesses/apollo/hooks/check-changelog-presence.mjs +48 -0
- package/data/harnesses/apollo/hooks/check-completion-integrity.mjs +68 -0
- package/data/harnesses/apollo/hooks/check-ownership.mjs +22 -0
- package/data/harnesses/apollo/hooks/compliance.mjs +359 -0
- package/data/harnesses/apollo/hooks/hooks.json +16 -10
- package/data/harnesses/apollo/hooks/lib/frozen.mjs +58 -0
- package/data/harnesses/apollo/hooks/lib/ownership.mjs +223 -0
- package/data/harnesses/apollo/hooks/lib/state.mjs +157 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +10 -0
- package/data/harnesses/code/hooks/compliance.mjs +34 -7
- package/data/harnesses/code/hooks/lib/state.mjs +6 -5
- package/package.json +3 -2
- package/data/harnesses/apollo/hooks/generate-ownership-index.mjs +0 -148
package/data/harness-index.json
CHANGED
|
@@ -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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "apollo",
|
|
3
3
|
"name": "apollo",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.3",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
"docs/history/backlog.md",
|
|
15
15
|
"docs/memory/main.md"
|
|
16
16
|
],
|
|
17
|
-
"digest": "
|
|
17
|
+
"digest": "25bb37bef74c"
|
|
18
18
|
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// harness/code/hooks/awareness.mjs — the inform-never-block hook module.
|
|
2
|
+
// SessionStart: inject the session context block, the ownership index, and a budget-capped drift
|
|
3
|
+
// banner (full report → .claude/state/drift-report.md). PostToolUse (Edit|Write|MultiEdit|Bash):
|
|
4
|
+
// the silent detector — record file→doc touches, append machine-recomputable findings as auto:
|
|
5
|
+
// rows to docs/operations/bookkeeping.md. Never narrates into the session, never blocks, exits 0
|
|
6
|
+
// on every path: everything here is a convenience, absent on any failure.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import {
|
|
12
|
+
readStdin,
|
|
13
|
+
resolveProjectDir,
|
|
14
|
+
buildIndex,
|
|
15
|
+
scanFeatureDocs,
|
|
16
|
+
owningDocs,
|
|
17
|
+
listRepoFiles,
|
|
18
|
+
deadGlobs,
|
|
19
|
+
globToRegExp,
|
|
20
|
+
frontmatter,
|
|
21
|
+
} from "./lib/ownership.mjs";
|
|
22
|
+
import {
|
|
23
|
+
readQueue,
|
|
24
|
+
countRows,
|
|
25
|
+
formatAutoRow,
|
|
26
|
+
appendRows,
|
|
27
|
+
resolveRows,
|
|
28
|
+
unexpiredWaivers,
|
|
29
|
+
stateDir,
|
|
30
|
+
recordTouched,
|
|
31
|
+
} from "./lib/state.mjs";
|
|
32
|
+
|
|
33
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
34
|
+
|
|
35
|
+
function readSafe(file) {
|
|
36
|
+
try {
|
|
37
|
+
return fs.readFileSync(file, "utf8");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJsonSafe(file) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// SessionStart
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function detectPackageManager(projectDir) {
|
|
56
|
+
const lockfiles = [
|
|
57
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
58
|
+
["package-lock.json", "npm"],
|
|
59
|
+
["yarn.lock", "yarn"],
|
|
60
|
+
["bun.lockb", "bun"],
|
|
61
|
+
["bun.lock", "bun"],
|
|
62
|
+
];
|
|
63
|
+
for (const [file, manager] of lockfiles) {
|
|
64
|
+
if (fs.existsSync(path.join(projectDir, file))) return `${manager} (${file} found)`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function harnessLine(projectDir) {
|
|
70
|
+
const marker = readJsonSafe(path.join(projectDir, ".claude", "harness.json"));
|
|
71
|
+
if (!marker || !marker.version) return null;
|
|
72
|
+
const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
|
|
73
|
+
const stamped = (/harness-version:\s*([\w.-]+)/.exec(frontmatter(structure) || structure) || [])[1];
|
|
74
|
+
const state =
|
|
75
|
+
stamped === marker.version
|
|
76
|
+
? "reconciled"
|
|
77
|
+
: stamped
|
|
78
|
+
? `stamped ${stamped} — behind: run maintenance`
|
|
79
|
+
: "unstamped — run maintenance";
|
|
80
|
+
return `Harness: installed ${marker.version} · docs/structure.md ${state}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function memoryLine(projectDir) {
|
|
84
|
+
const dir = path.join(projectDir, "docs", "memory");
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return entries.length ? `Memory: ${entries.map((f) => `docs/memory/${f}`).join(", ")}` : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openPlanLines(projectDir) {
|
|
95
|
+
const dir = path.join(projectDir, "docs", "history", "plans");
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const lines = [];
|
|
103
|
+
for (const file of entries) {
|
|
104
|
+
const text = readSafe(path.join(dir, file));
|
|
105
|
+
const status = (/^\*\*Status:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
|
|
106
|
+
if (!status || /Complete|Abandoned/.test(status)) continue;
|
|
107
|
+
const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
|
|
108
|
+
const ledgerRows = (text.match(/^\|\s*\d+\s*\|/gm) || []).length;
|
|
109
|
+
const parts = [`Open plan: ${file} (${status}`];
|
|
110
|
+
if (gates) parts.push(` · Gates: ${gates}`);
|
|
111
|
+
if (ledgerRows) parts.push(` · ledger: ${ledgerRows} rows`);
|
|
112
|
+
lines.push(parts.join("") + ")");
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Compute drift facts once; write the full report, return the one-line banner.
|
|
118
|
+
function driftBanner(projectDir) {
|
|
119
|
+
const repoFiles = listRepoFiles(projectDir);
|
|
120
|
+
const dead = deadGlobs(projectDir, repoFiles);
|
|
121
|
+
const queueRows = readQueue(projectDir);
|
|
122
|
+
const counts = countRows(queueRows);
|
|
123
|
+
const waivers = unexpiredWaivers(queueRows, "dead-glob", today());
|
|
124
|
+
const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
|
|
125
|
+
|
|
126
|
+
// A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
|
|
127
|
+
// so waiving a finding class actually empties the rows it supersedes.
|
|
128
|
+
resolveRows(projectDir, (row) => {
|
|
129
|
+
if (row.kind !== "auto" || !row.body.includes("dead-glob")) return false;
|
|
130
|
+
const m = /`([^`]+)`/.exec(row.body);
|
|
131
|
+
return !!m && waivers.some((w) => w.body.includes(`\`${m[1]}\``));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Docs older than the code they own (doc mtime < newest owned-file mtime).
|
|
135
|
+
const staleDocs = [];
|
|
136
|
+
for (const entry of scanFeatureDocs(projectDir)) {
|
|
137
|
+
let docTime;
|
|
138
|
+
try {
|
|
139
|
+
docTime = fs.statSync(path.join(projectDir, entry.doc)).mtimeMs;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const res = entry.owns.map((g) => globToRegExp(g));
|
|
144
|
+
let newest = 0;
|
|
145
|
+
for (const file of repoFiles) {
|
|
146
|
+
if (!res.some((re) => re.test(file))) continue;
|
|
147
|
+
try {
|
|
148
|
+
newest = Math.max(newest, fs.statSync(path.join(projectDir, file)).mtimeMs);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
/* deleted between walk and stat */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (newest > docTime) staleDocs.push(entry.doc);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const report = [
|
|
157
|
+
`# Drift report — generated ${new Date().toISOString()}`,
|
|
158
|
+
"",
|
|
159
|
+
`## Dead owns: globs (${dead.length}, ${dead.length - unwaivedDead.length} waived)`,
|
|
160
|
+
...dead.map((d) => `- ${d.glob} → ${d.doc}${unwaivedDead.includes(d) ? "" : " (waived)"}`),
|
|
161
|
+
"",
|
|
162
|
+
`## Docs older than owned code (${staleDocs.length})`,
|
|
163
|
+
...staleDocs.map((d) => `- ${d}`),
|
|
164
|
+
"",
|
|
165
|
+
`## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.waived} waived)`,
|
|
166
|
+
].join("\n");
|
|
167
|
+
try {
|
|
168
|
+
fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
|
|
169
|
+
} catch (e) {
|
|
170
|
+
/* report is a convenience */
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (dead.length === 0 && staleDocs.length === 0 && counts.unresolved === 0) return null;
|
|
174
|
+
const waivedNote = dead.length
|
|
175
|
+
? unwaivedDead.length === 0
|
|
176
|
+
? " (all waived)"
|
|
177
|
+
: ` (${unwaivedDead.length} UNWAIVED)`
|
|
178
|
+
: "";
|
|
179
|
+
return (
|
|
180
|
+
`Drift: ${dead.length} dead owns: glob(s)${waivedNote} · ${staleDocs.length} doc(s) older than owned code · ` +
|
|
181
|
+
`bookkeeping: ${counts.unresolved} unresolved row(s) — full report: .claude/state/drift-report.md`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sessionStart(projectDir) {
|
|
186
|
+
const lines = [];
|
|
187
|
+
const pm = detectPackageManager(projectDir);
|
|
188
|
+
if (pm) lines.push(`Package manager: ${pm}`);
|
|
189
|
+
const harness = harnessLine(projectDir);
|
|
190
|
+
if (harness) lines.push(harness);
|
|
191
|
+
const memory = memoryLine(projectDir);
|
|
192
|
+
if (memory) lines.push(memory);
|
|
193
|
+
lines.push(...openPlanLines(projectDir).slice(0, 4));
|
|
194
|
+
try {
|
|
195
|
+
const drift = driftBanner(projectDir);
|
|
196
|
+
if (drift) lines.push(drift);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
/* the banner is optional; the index below still injects */
|
|
199
|
+
}
|
|
200
|
+
const block = lines.length
|
|
201
|
+
? "# Session context (generated — a convenience, never a gate)\n" + lines.slice(0, 10).join("\n")
|
|
202
|
+
: "";
|
|
203
|
+
const index = buildIndex(projectDir);
|
|
204
|
+
const context = [block, index].filter(Boolean).join("\n\n");
|
|
205
|
+
if (!context) return;
|
|
206
|
+
process.stdout.write(
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// PostToolUse — the silent detector
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
|
|
218
|
+
|
|
219
|
+
function relIfInside(projectDir, filePath) {
|
|
220
|
+
if (!filePath) return null;
|
|
221
|
+
const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
|
|
222
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
223
|
+
return rel.split(path.sep).join("/");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
|
|
227
|
+
function docLint(projectDir, rel) {
|
|
228
|
+
const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
|
|
229
|
+
if (!fs.existsSync(script)) return [];
|
|
230
|
+
const res = spawnSync("node", [script, rel], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
|
|
231
|
+
const out = `${res.stdout || ""}`;
|
|
232
|
+
return out.split("\n").filter((line) => /:\d+ \[(block|warn)\]/.test(line));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// True when a source file's first lines carry its repo-relative path (the header-comment rule).
|
|
236
|
+
function hasHeaderComment(projectDir, rel) {
|
|
237
|
+
const head = readSafe(path.join(projectDir, rel)).split("\n").slice(0, 5).join("\n");
|
|
238
|
+
return head.includes(rel) || head.includes(path.basename(rel));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function detectorForFile(projectDir, rel, meta, rows) {
|
|
242
|
+
const owners = owningDocs(projectDir, rel);
|
|
243
|
+
recordTouched(projectDir, rel, owners.map((o) => o.doc));
|
|
244
|
+
|
|
245
|
+
if (rel.endsWith(".md")) {
|
|
246
|
+
// An edited doc resolves any doc-implicated row that pointed at it.
|
|
247
|
+
resolveRows(projectDir, (row) => row.kind === "auto" && row.body.includes("doc-implicated") && row.body.includes(rel));
|
|
248
|
+
if (rel.startsWith("docs/")) {
|
|
249
|
+
const findings = docLint(projectDir, rel);
|
|
250
|
+
if (findings.length) {
|
|
251
|
+
rows.push(
|
|
252
|
+
formatAutoRow({
|
|
253
|
+
...meta,
|
|
254
|
+
kind: "doc-lint",
|
|
255
|
+
description: `${rel} has ${findings.length} doc-lint finding(s)`,
|
|
256
|
+
action: `fix or doc-lint-disable with reason`,
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const segment = rel.split("/")[1];
|
|
262
|
+
if (rel.startsWith("docs/") && segment && !segment.endsWith(".md")) {
|
|
263
|
+
const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
|
|
264
|
+
if (structure && !structure.includes(`${segment}/`) && !structure.includes(`docs/${segment}`)) {
|
|
265
|
+
rows.push(
|
|
266
|
+
formatAutoRow({
|
|
267
|
+
...meta,
|
|
268
|
+
kind: "doc-placement",
|
|
269
|
+
description: `${rel} sits in a folder absent from docs/structure.md's authority map`,
|
|
270
|
+
action: "move the doc per the add-a-doc table, or record the new folder in structure.md",
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (rel.startsWith("docs/features/") && !readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(rel))) {
|
|
276
|
+
rows.push(
|
|
277
|
+
formatAutoRow({
|
|
278
|
+
...meta,
|
|
279
|
+
kind: "index-row-missing",
|
|
280
|
+
description: `${rel} is not linked from docs/README.md`,
|
|
281
|
+
action: "add its row to the Features index",
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (SOURCE_EXT.test(rel)) {
|
|
289
|
+
if (!hasHeaderComment(projectDir, rel)) {
|
|
290
|
+
rows.push(
|
|
291
|
+
formatAutoRow({
|
|
292
|
+
...meta,
|
|
293
|
+
kind: "header-missing",
|
|
294
|
+
description: `${rel} lacks its file-path header comment`,
|
|
295
|
+
action: "add the header comment",
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
for (const owner of owners) {
|
|
300
|
+
rows.push(
|
|
301
|
+
formatAutoRow({
|
|
302
|
+
...meta,
|
|
303
|
+
kind: "doc-implicated",
|
|
304
|
+
description: `${rel} changed; owning doc ${owner.doc} may need an update (doc-implicated: ${owner.doc})`,
|
|
305
|
+
action: "update the doc or delete this row if no documented behavior changed",
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if (/(schema|init\.sql|migration|auth|webhook|\benv\b|wrangler)/i.test(rel)) {
|
|
310
|
+
rows.push(
|
|
311
|
+
formatAutoRow({
|
|
312
|
+
...meta,
|
|
313
|
+
kind: "boundary-scan-suggested",
|
|
314
|
+
description: `${rel} is contract-heavy`,
|
|
315
|
+
action: "run the boundary skill scoped to the diff",
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function detectRenames(projectDir, command, meta, rows) {
|
|
323
|
+
if (!/\bgit mv\b|\bmv\b/.test(command || "")) return;
|
|
324
|
+
const waivers = unexpiredWaivers(readQueue(projectDir), "dead-glob", today());
|
|
325
|
+
for (const { glob, doc } of deadGlobs(projectDir)) {
|
|
326
|
+
if (waivers.some((w) => w.body.includes(`\`${glob}\``))) continue;
|
|
327
|
+
rows.push(
|
|
328
|
+
formatAutoRow({
|
|
329
|
+
...meta,
|
|
330
|
+
kind: "dead-glob",
|
|
331
|
+
description: `owns: glob \`${glob}\` in ${doc} matches no files`,
|
|
332
|
+
action: `update the doc's owns: frontmatter`,
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function postToolUse(projectDir, payload) {
|
|
339
|
+
const meta = {
|
|
340
|
+
date: today(),
|
|
341
|
+
sessionId: payload.session_id,
|
|
342
|
+
transcriptPath: payload.transcript_path,
|
|
343
|
+
ts: new Date().toISOString(),
|
|
344
|
+
};
|
|
345
|
+
const input = payload.tool_input || {};
|
|
346
|
+
const rows = [];
|
|
347
|
+
if (payload.tool_name === "Bash") {
|
|
348
|
+
detectRenames(projectDir, input.command, meta, rows);
|
|
349
|
+
} else {
|
|
350
|
+
const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
|
|
351
|
+
if (rel) detectorForFile(projectDir, rel, meta, rows);
|
|
352
|
+
// Editing a feature doc's frontmatter can orphan globs the same way a rename can.
|
|
353
|
+
if (rel && rel.startsWith("docs/features/")) detectRenames(projectDir, "git mv", meta, rows);
|
|
354
|
+
}
|
|
355
|
+
appendRows(projectDir, rows);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function main() {
|
|
361
|
+
const raw = readStdin();
|
|
362
|
+
let payload = {};
|
|
363
|
+
try {
|
|
364
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
365
|
+
} catch (e) {
|
|
366
|
+
payload = {};
|
|
367
|
+
}
|
|
368
|
+
const projectDir = resolveProjectDir(payload);
|
|
369
|
+
const event = payload.hook_event_name || "SessionStart";
|
|
370
|
+
if (event === "PostToolUse") postToolUse(projectDir, payload);
|
|
371
|
+
else sessionStart(projectDir);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
main();
|
|
376
|
+
} catch (e) {
|
|
377
|
+
// Inform-only module: on any failure, inject nothing, write nothing, never block.
|
|
378
|
+
} finally {
|
|
379
|
+
process.exit(0);
|
|
380
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// harness/code/hooks/check-bookkeeping-clean.mjs — CI backstop: no unresolved bookkeeping rows
|
|
2
|
+
// (auto:/note:/needs-session:) may reach the remote. waived: rows pass — they are the recorded,
|
|
3
|
+
// PR-reviewable exceptions. Exit 1 when unresolved rows exist.
|
|
4
|
+
|
|
5
|
+
import { readQueue, countRows } from "./lib/state.mjs";
|
|
6
|
+
|
|
7
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
8
|
+
const counts = countRows(readQueue(projectDir));
|
|
9
|
+
|
|
10
|
+
if (counts.unresolved === 0) {
|
|
11
|
+
console.log(`check-bookkeeping-clean: OK (${counts.waived} waived row(s))`);
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
console.error(
|
|
15
|
+
`check-bookkeeping-clean: ${counts.unresolved} unresolved row(s) in docs/operations/bookkeeping.md ` +
|
|
16
|
+
`(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session). ` +
|
|
17
|
+
"Drain them (bookkeeper agent), resolve them, or convert to waived: with reason + expires=."
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// harness/code/hooks/check-changelog-presence.mjs — CI backstop: the commit under test (HEAD)
|
|
2
|
+
// carries a docs/history/changelog/ entry unless it is exempt by the changelog rule's closed list,
|
|
3
|
+
// judged mechanically: lockfile-only, harness-file-only, pure renames, changelog housekeeping,
|
|
4
|
+
// or a bot author. Exit 1 when a non-exempt HEAD stages no entry.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
10
|
+
|
|
11
|
+
function git(args) {
|
|
12
|
+
const res = spawnSync("git", args, { cwd: projectDir, encoding: "utf8" });
|
|
13
|
+
return res.status === 0 ? (res.stdout || "").trim() : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const nameStatus = git(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "HEAD"]) || "";
|
|
17
|
+
const entries = nameStatus
|
|
18
|
+
.split("\n")
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((line) => {
|
|
21
|
+
const [status, ...files] = line.split("\t");
|
|
22
|
+
return { status, file: files[files.length - 1] };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
|
|
26
|
+
const exemptFile = (f) =>
|
|
27
|
+
LOCKFILES.test(path.basename(f)) ||
|
|
28
|
+
f.startsWith(".claude/") ||
|
|
29
|
+
f === "AGENTS.md" ||
|
|
30
|
+
f === "CLAUDE.md" ||
|
|
31
|
+
f.startsWith("docs/history/changelog/") ||
|
|
32
|
+
f === "docs/history/CHANGELOG.md";
|
|
33
|
+
|
|
34
|
+
const author = git(["log", "-1", "--format=%an <%ae>"]) || "";
|
|
35
|
+
const isBot = /\[bot\]|actions@github\.com/i.test(author);
|
|
36
|
+
const allRenames = entries.length > 0 && entries.every((e) => e.status.startsWith("R"));
|
|
37
|
+
const allExempt = entries.length === 0 || entries.every((e) => exemptFile(e.file));
|
|
38
|
+
const hasEntry = entries.some((e) => e.file.startsWith("docs/history/changelog/") && e.file.endsWith(".md"));
|
|
39
|
+
|
|
40
|
+
if (isBot || allRenames || allExempt || hasEntry) {
|
|
41
|
+
console.log("check-changelog-presence: OK");
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
console.error(
|
|
45
|
+
"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)."
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// harness/code/hooks/check-completion-integrity.mjs — CI backstop for plan completion. Scope:
|
|
2
|
+
// plans in the Gates: format only (legacy plans predate the contract and are history). A Complete
|
|
3
|
+
// plan must show a fully discharged surface: no unchecked Done Criteria boxes, the Spec'd LLD
|
|
4
|
+
// flipped active, and the behavioral checklist present. An Abandoned plan must reference the
|
|
5
|
+
// backlog (undischarged obligations outlive the frozen file there). Exit 1 on any violation.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { frontmatter, field } from "./lib/ownership.mjs";
|
|
10
|
+
|
|
11
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
12
|
+
const plansDir = path.join(projectDir, "docs", "history", "plans");
|
|
13
|
+
|
|
14
|
+
function readSafe(file) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readFileSync(file, "utf8");
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let failures = 0;
|
|
23
|
+
const fail = (msg) => {
|
|
24
|
+
failures += 1;
|
|
25
|
+
console.error(`check-completion-integrity: ${msg}`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let plans = [];
|
|
29
|
+
try {
|
|
30
|
+
plans = fs.readdirSync(plansDir).filter((f) => f.endsWith(".md"));
|
|
31
|
+
} catch (e) {
|
|
32
|
+
plans = [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const file of plans) {
|
|
36
|
+
const text = readSafe(path.join(plansDir, file));
|
|
37
|
+
if (!/^\*\*Gates:\*\*/m.test(text)) continue; // legacy plan — out of contract
|
|
38
|
+
const status = (/^\*\*Status:\*\*\s*(\S+)/m.exec(text) || [])[1];
|
|
39
|
+
|
|
40
|
+
if (status === "Complete") {
|
|
41
|
+
const unchecked = (text.match(/^- \[ \]/gm) || []).length;
|
|
42
|
+
if (unchecked > 0) fail(`${file} is Complete with ${unchecked} unchecked Done Criteria box(es)`);
|
|
43
|
+
|
|
44
|
+
const spec = (/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/m.exec(text) || [])[1];
|
|
45
|
+
if (spec) {
|
|
46
|
+
const specPath = path.resolve(plansDir, spec);
|
|
47
|
+
const specText = readSafe(specPath);
|
|
48
|
+
const specStatus = (field(frontmatter(specText), "status") || "").toString();
|
|
49
|
+
if (specText && specStatus !== "active") {
|
|
50
|
+
fail(`${file} is Complete but its Spec LLD is status: ${specStatus || "unknown"} (expected active)`);
|
|
51
|
+
}
|
|
52
|
+
const feature = path.basename(specPath, ".md");
|
|
53
|
+
if (!fs.existsSync(path.join(projectDir, "docs", "tests", `${feature}.md`))) {
|
|
54
|
+
fail(`${file} is Complete but docs/tests/${feature}.md (the behavioral checklist) is missing`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (status === "Abandoned" && !/backlog/i.test(text)) {
|
|
60
|
+
fail(`${file} is Abandoned without referencing the backlog — undischarged obligations must outlive the frozen plan`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (failures === 0) {
|
|
65
|
+
console.log("check-completion-integrity: OK");
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
process.exit(1);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// harness/code/hooks/check-ownership.mjs — CI backstop: every owns: glob must match at least one
|
|
2
|
+
// file, unless an unexpired `waived:` dead-glob row in docs/operations/bookkeeping.md covers it
|
|
3
|
+
// (the waiver names the glob verbatim). Exit 1 on any unwaived dead glob.
|
|
4
|
+
|
|
5
|
+
import { deadGlobs } from "./lib/ownership.mjs";
|
|
6
|
+
import { readQueue, unexpiredWaivers } from "./lib/state.mjs";
|
|
7
|
+
|
|
8
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
9
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
10
|
+
|
|
11
|
+
const dead = deadGlobs(projectDir);
|
|
12
|
+
const waivers = unexpiredWaivers(readQueue(projectDir), "dead-glob", today);
|
|
13
|
+
const unwaived = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
|
|
14
|
+
|
|
15
|
+
if (unwaived.length === 0) {
|
|
16
|
+
console.log(`check-ownership: OK (${dead.length} dead glob(s), all waived or none)`);
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
console.error(`check-ownership: ${unwaived.length} unwaived dead owns: glob(s):`);
|
|
20
|
+
for (const d of unwaived) console.error(` ${d.glob} → ${d.doc}`);
|
|
21
|
+
console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row with reason + expires=.");
|
|
22
|
+
process.exit(1);
|