@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.
- package/bin/etna.js +33 -6
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/agents/bookkeeper.md +27 -0
- package/data/harnesses/apollo/agents/maintenance.md +3 -1
- package/data/harnesses/apollo/harness.json +2 -2
- package/data/harnesses/apollo/hooks/awareness.mjs +370 -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 +353 -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 +156 -0
- package/data/harnesses/apollo/skills/boundary/SKILL.md +3 -1
- package/data/harnesses/apollo/skills/documentation/SKILL.md +2 -0
- package/data/harnesses/apollo/skills/documentation/templates/Readme.md +2 -0
- package/data/harnesses/apollo/skills/documentation/templates/bookkeeping.md +18 -0
- package/data/harnesses/apollo/skills/documentation/templates/journal.md +13 -0
- package/data/harnesses/apollo/skills/documentation/templates/plan.md +3 -0
- package/data/harnesses/apollo/skills/test-plan/SKILL.md +2 -0
- package/data/harnesses/code/agents/bookkeeper.md +27 -0
- package/data/harnesses/code/agents/maintenance.md +3 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +370 -0
- package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +19 -0
- package/data/harnesses/code/hooks/check-changelog-presence.mjs +48 -0
- package/data/harnesses/code/hooks/check-completion-integrity.mjs +68 -0
- package/data/harnesses/code/hooks/check-ownership.mjs +22 -0
- package/data/harnesses/code/hooks/compliance.mjs +353 -0
- package/data/harnesses/code/hooks/hooks.json +16 -10
- package/data/harnesses/code/hooks/lib/frozen.mjs +58 -0
- package/data/harnesses/code/hooks/lib/ownership.mjs +223 -0
- package/data/harnesses/code/hooks/lib/state.mjs +156 -0
- package/data/harnesses/code/skills/boundary/SKILL.md +3 -1
- package/data/harnesses/code/skills/documentation/SKILL.md +2 -0
- package/data/harnesses/code/skills/documentation/templates/Readme.md +2 -0
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +18 -0
- package/data/harnesses/code/skills/documentation/templates/journal.md +13 -0
- package/data/harnesses/code/skills/documentation/templates/plan.md +3 -0
- package/data/harnesses/code/skills/test-plan/SKILL.md +2 -0
- package/data/harnesses/delivery/agents/maintenance.md +3 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +2 -0
- package/data/harnesses/delivery/skills/documentation/templates/Readme.md +2 -0
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +18 -0
- package/data/harnesses/delivery/skills/documentation/templates/journal.md +13 -0
- package/data/harnesses/delivery/skills/documentation/templates/plan.md +3 -0
- package/data/harnesses/etna/agents/maintenance.md +3 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/boundary/SKILL.md +3 -1
- package/data/harnesses/etna/skills/documentation/SKILL.md +2 -0
- package/data/harnesses/etna/skills/documentation/templates/Readme.md +2 -0
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +18 -0
- package/data/harnesses/etna/skills/documentation/templates/journal.md +13 -0
- package/data/harnesses/etna/skills/documentation/templates/plan.md +3 -0
- package/package.json +2 -2
- package/data/harnesses/apollo/hooks/generate-ownership-index.mjs +0 -148
- package/data/harnesses/code/hooks/generate-ownership-index.mjs +0 -148
package/bin/etna.js
CHANGED
|
@@ -162,16 +162,32 @@ function migrateFromPluginInstall(targetDir, pluginName) {
|
|
|
162
162
|
// Hook scripts shipped by earlier harness versions (the legacy memory-ledger tier) and since retired.
|
|
163
163
|
// mergeHooks is additive, so an upgrade must explicitly remove both the stale settings.json
|
|
164
164
|
// entries pointing at these and the leftover script files they reference.
|
|
165
|
-
const RETIRED_HOOKS = [
|
|
165
|
+
const RETIRED_HOOKS = [
|
|
166
|
+
'session-stop.js',
|
|
167
|
+
'post-tool-failure.js',
|
|
168
|
+
'post-commit.js',
|
|
169
|
+
'generate-ownership-index.mjs', // folded into awareness.mjs — unretired, the index would inject twice
|
|
170
|
+
];
|
|
166
171
|
|
|
167
172
|
// Remove settings.json hook entries and .claude/hooks/ files for retired hook scripts.
|
|
168
|
-
|
|
173
|
+
// Bundle-aware: a name on the retired list is skipped while THIS bundle still ships it —
|
|
174
|
+
// several harnesses share the list, and they retire the same script at different versions.
|
|
175
|
+
function retireOldHooks(targetDir, bundledHarnessDir) {
|
|
169
176
|
const removed = [];
|
|
177
|
+
const bundledHooksDir = join(bundledHarnessDir, 'hooks');
|
|
178
|
+
const stillShipped = new Set(
|
|
179
|
+
existsSync(bundledHooksDir)
|
|
180
|
+
? readdirSync(bundledHooksDir, { withFileTypes: true })
|
|
181
|
+
.filter((e) => e.isFile())
|
|
182
|
+
.map((e) => e.name)
|
|
183
|
+
: [],
|
|
184
|
+
);
|
|
185
|
+
const retired = RETIRED_HOOKS.filter((name) => !stillShipped.has(name));
|
|
170
186
|
const settingsPath = join(targetDir, '.claude', 'settings.json');
|
|
171
187
|
if (existsSync(settingsPath)) {
|
|
172
188
|
const settings = readJson(settingsPath);
|
|
173
189
|
const isRetired = (h) =>
|
|
174
|
-
|
|
190
|
+
retired.some((name) => (h.command || '').includes(`/hooks/${name}`));
|
|
175
191
|
let pruned = 0;
|
|
176
192
|
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
177
193
|
for (const group of groups) {
|
|
@@ -187,7 +203,7 @@ function retireOldHooks(targetDir) {
|
|
|
187
203
|
removed.push(`${pruned} retired hook entr${pruned === 1 ? 'y' : 'ies'} in settings.json`);
|
|
188
204
|
}
|
|
189
205
|
}
|
|
190
|
-
for (const name of
|
|
206
|
+
for (const name of retired) {
|
|
191
207
|
const file = join(targetDir, '.claude', 'hooks', name);
|
|
192
208
|
if (existsSync(file)) {
|
|
193
209
|
rmSync(file, { force: true });
|
|
@@ -296,11 +312,22 @@ function groupHasCommand(group, command) {
|
|
|
296
312
|
// already exists there; a new matcher creates a new group. Returns count of hooks added.
|
|
297
313
|
function mergeHooks(targetDir, bundledHooksJson) {
|
|
298
314
|
if (!existsSync(bundledHooksJson)) return 0;
|
|
299
|
-
const
|
|
315
|
+
const bundled = readJson(bundledHooksJson);
|
|
316
|
+
const incoming = bundled.hooks || {};
|
|
300
317
|
const settingsPath = join(targetDir, '.claude', 'settings.json');
|
|
301
318
|
const settings = readJson(settingsPath);
|
|
302
319
|
settings.hooks = settings.hooks || {};
|
|
303
320
|
let added = 0;
|
|
321
|
+
// Harness-shipped permission defaults (e.g. Edit/Write on docs/** flow without prompts —
|
|
322
|
+
// the deny hooks still hard-block frozen/protected paths underneath). Additive, idempotent.
|
|
323
|
+
for (const rule of bundled.permissions?.allow || []) {
|
|
324
|
+
settings.permissions = settings.permissions || {};
|
|
325
|
+
const allowList = (settings.permissions.allow = settings.permissions.allow || []);
|
|
326
|
+
if (!allowList.includes(rule)) {
|
|
327
|
+
allowList.push(rule);
|
|
328
|
+
added += 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
304
331
|
for (const [event, groups] of Object.entries(incoming)) {
|
|
305
332
|
const existingGroups = (settings.hooks[event] = settings.hooks[event] || []);
|
|
306
333
|
for (const group of groups) {
|
|
@@ -504,7 +531,7 @@ async function installHarness(harnessName, harnesses) {
|
|
|
504
531
|
|
|
505
532
|
// Retire hooks earlier harness versions shipped before merging, so the additive merge
|
|
506
533
|
// never resurrects an entry pointing at a script this bundle no longer carries.
|
|
507
|
-
for (const r of retireOldHooks(targetDir)) notices.push(`removed ${r}`);
|
|
534
|
+
for (const r of retireOldHooks(targetDir, bundledHarnessDir)) notices.push(`removed ${r}`);
|
|
508
535
|
|
|
509
536
|
// Merge the bundle's hooks.json into the project's settings.json (idempotent).
|
|
510
537
|
const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
|
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.
|
|
@@ -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,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": "544ba9e97120"
|
|
18
18
|
}
|
|
@@ -0,0 +1,370 @@
|
|
|
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
|
+
// Docs older than the code they own (doc mtime < newest owned-file mtime).
|
|
127
|
+
const staleDocs = [];
|
|
128
|
+
for (const entry of scanFeatureDocs(projectDir)) {
|
|
129
|
+
let docTime;
|
|
130
|
+
try {
|
|
131
|
+
docTime = fs.statSync(path.join(projectDir, entry.doc)).mtimeMs;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const res = entry.owns.map((g) => globToRegExp(g));
|
|
136
|
+
let newest = 0;
|
|
137
|
+
for (const file of repoFiles) {
|
|
138
|
+
if (!res.some((re) => re.test(file))) continue;
|
|
139
|
+
try {
|
|
140
|
+
newest = Math.max(newest, fs.statSync(path.join(projectDir, file)).mtimeMs);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
/* deleted between walk and stat */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (newest > docTime) staleDocs.push(entry.doc);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const report = [
|
|
149
|
+
`# Drift report — generated ${new Date().toISOString()}`,
|
|
150
|
+
"",
|
|
151
|
+
`## Dead owns: globs (${dead.length}, ${dead.length - unwaivedDead.length} waived)`,
|
|
152
|
+
...dead.map((d) => `- ${d.glob} → ${d.doc}${unwaivedDead.includes(d) ? "" : " (waived)"}`),
|
|
153
|
+
"",
|
|
154
|
+
`## Docs older than owned code (${staleDocs.length})`,
|
|
155
|
+
...staleDocs.map((d) => `- ${d}`),
|
|
156
|
+
"",
|
|
157
|
+
`## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.waived} waived)`,
|
|
158
|
+
].join("\n");
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
|
|
161
|
+
} catch (e) {
|
|
162
|
+
/* report is a convenience */
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (dead.length === 0 && staleDocs.length === 0 && counts.unresolved === 0) return null;
|
|
166
|
+
const waivedNote = dead.length
|
|
167
|
+
? unwaivedDead.length === 0
|
|
168
|
+
? " (all waived)"
|
|
169
|
+
: ` (${unwaivedDead.length} UNWAIVED)`
|
|
170
|
+
: "";
|
|
171
|
+
return (
|
|
172
|
+
`Drift: ${dead.length} dead owns: glob(s)${waivedNote} · ${staleDocs.length} doc(s) older than owned code · ` +
|
|
173
|
+
`bookkeeping: ${counts.unresolved} unresolved row(s) — full report: .claude/state/drift-report.md`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sessionStart(projectDir) {
|
|
178
|
+
const lines = [];
|
|
179
|
+
const pm = detectPackageManager(projectDir);
|
|
180
|
+
if (pm) lines.push(`Package manager: ${pm}`);
|
|
181
|
+
const harness = harnessLine(projectDir);
|
|
182
|
+
if (harness) lines.push(harness);
|
|
183
|
+
const memory = memoryLine(projectDir);
|
|
184
|
+
if (memory) lines.push(memory);
|
|
185
|
+
lines.push(...openPlanLines(projectDir).slice(0, 4));
|
|
186
|
+
try {
|
|
187
|
+
const drift = driftBanner(projectDir);
|
|
188
|
+
if (drift) lines.push(drift);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
/* the banner is optional; the index below still injects */
|
|
191
|
+
}
|
|
192
|
+
const block = lines.length
|
|
193
|
+
? "# Session context (generated — a convenience, never a gate)\n" + lines.slice(0, 10).join("\n")
|
|
194
|
+
: "";
|
|
195
|
+
const index = buildIndex(projectDir);
|
|
196
|
+
const context = [block, index].filter(Boolean).join("\n\n");
|
|
197
|
+
if (!context) return;
|
|
198
|
+
process.stdout.write(
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// PostToolUse — the silent detector
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
|
|
210
|
+
|
|
211
|
+
function relIfInside(projectDir, filePath) {
|
|
212
|
+
if (!filePath) return null;
|
|
213
|
+
const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
|
|
214
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
215
|
+
return rel.split(path.sep).join("/");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
|
|
219
|
+
function docLint(projectDir, rel) {
|
|
220
|
+
const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
|
|
221
|
+
if (!fs.existsSync(script)) return [];
|
|
222
|
+
const res = spawnSync("node", [script, rel], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
|
|
223
|
+
const out = `${res.stdout || ""}`;
|
|
224
|
+
return out.split("\n").filter((line) => /:\d+ \[(block|warn)\]/.test(line));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// True when a source file's first lines carry its repo-relative path (the header-comment rule).
|
|
228
|
+
function hasHeaderComment(projectDir, rel) {
|
|
229
|
+
const head = readSafe(path.join(projectDir, rel)).split("\n").slice(0, 5).join("\n");
|
|
230
|
+
return head.includes(rel) || head.includes(path.basename(rel));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function detectorForFile(projectDir, rel, meta, rows) {
|
|
234
|
+
const owners = owningDocs(projectDir, rel);
|
|
235
|
+
recordTouched(projectDir, rel, owners.map((o) => o.doc));
|
|
236
|
+
|
|
237
|
+
if (rel.endsWith(".md")) {
|
|
238
|
+
// An edited doc resolves any doc-implicated row that pointed at it.
|
|
239
|
+
resolveRows(projectDir, (row) => row.kind === "auto" && row.body.includes("doc-implicated") && row.body.includes(rel));
|
|
240
|
+
if (rel.startsWith("docs/")) {
|
|
241
|
+
const findings = docLint(projectDir, rel);
|
|
242
|
+
if (findings.length) {
|
|
243
|
+
rows.push(
|
|
244
|
+
formatAutoRow({
|
|
245
|
+
...meta,
|
|
246
|
+
kind: "doc-lint",
|
|
247
|
+
description: `${rel} has ${findings.length} doc-lint finding(s)`,
|
|
248
|
+
action: `fix or doc-lint-disable with reason`,
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const segment = rel.split("/")[1];
|
|
254
|
+
if (rel.startsWith("docs/") && segment && !segment.endsWith(".md")) {
|
|
255
|
+
const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
|
|
256
|
+
if (structure && !structure.includes(`${segment}/`) && !structure.includes(`docs/${segment}`)) {
|
|
257
|
+
rows.push(
|
|
258
|
+
formatAutoRow({
|
|
259
|
+
...meta,
|
|
260
|
+
kind: "doc-placement",
|
|
261
|
+
description: `${rel} sits in a folder absent from docs/structure.md's authority map`,
|
|
262
|
+
action: "move the doc per the add-a-doc table, or record the new folder in structure.md",
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (rel.startsWith("docs/features/") && !readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(rel))) {
|
|
268
|
+
rows.push(
|
|
269
|
+
formatAutoRow({
|
|
270
|
+
...meta,
|
|
271
|
+
kind: "index-row-missing",
|
|
272
|
+
description: `${rel} is not linked from docs/README.md`,
|
|
273
|
+
action: "add its row to the Features index",
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (SOURCE_EXT.test(rel)) {
|
|
281
|
+
if (!hasHeaderComment(projectDir, rel)) {
|
|
282
|
+
rows.push(
|
|
283
|
+
formatAutoRow({
|
|
284
|
+
...meta,
|
|
285
|
+
kind: "header-missing",
|
|
286
|
+
description: `${rel} lacks its file-path header comment`,
|
|
287
|
+
action: "add the header comment",
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
for (const owner of owners) {
|
|
292
|
+
rows.push(
|
|
293
|
+
formatAutoRow({
|
|
294
|
+
...meta,
|
|
295
|
+
kind: "doc-implicated",
|
|
296
|
+
description: `${rel} changed; owning doc ${owner.doc} may need an update (doc-implicated: ${owner.doc})`,
|
|
297
|
+
action: "update the doc or delete this row if no documented behavior changed",
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (/(schema|init\.sql|migration|auth|webhook|\benv\b|wrangler)/i.test(rel)) {
|
|
302
|
+
rows.push(
|
|
303
|
+
formatAutoRow({
|
|
304
|
+
...meta,
|
|
305
|
+
kind: "boundary-scan-suggested",
|
|
306
|
+
description: `${rel} is contract-heavy`,
|
|
307
|
+
action: "run the boundary skill scoped to the diff",
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function detectRenames(projectDir, command, meta, rows) {
|
|
315
|
+
if (!/\bgit mv\b|\bmv\b/.test(command || "")) return;
|
|
316
|
+
for (const { glob, doc } of deadGlobs(projectDir)) {
|
|
317
|
+
rows.push(
|
|
318
|
+
formatAutoRow({
|
|
319
|
+
...meta,
|
|
320
|
+
kind: "dead-glob",
|
|
321
|
+
description: `owns: glob \`${glob}\` in ${doc} matches no files`,
|
|
322
|
+
action: `update the doc's owns: frontmatter`,
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function postToolUse(projectDir, payload) {
|
|
329
|
+
const meta = {
|
|
330
|
+
date: today(),
|
|
331
|
+
sessionId: payload.session_id,
|
|
332
|
+
transcriptPath: payload.transcript_path,
|
|
333
|
+
ts: new Date().toISOString(),
|
|
334
|
+
};
|
|
335
|
+
const input = payload.tool_input || {};
|
|
336
|
+
const rows = [];
|
|
337
|
+
if (payload.tool_name === "Bash") {
|
|
338
|
+
detectRenames(projectDir, input.command, meta, rows);
|
|
339
|
+
} else {
|
|
340
|
+
const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
|
|
341
|
+
if (rel) detectorForFile(projectDir, rel, meta, rows);
|
|
342
|
+
// Editing a feature doc's frontmatter can orphan globs the same way a rename can.
|
|
343
|
+
if (rel && rel.startsWith("docs/features/")) detectRenames(projectDir, "git mv", meta, rows);
|
|
344
|
+
}
|
|
345
|
+
appendRows(projectDir, rows);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
function main() {
|
|
351
|
+
const raw = readStdin();
|
|
352
|
+
let payload = {};
|
|
353
|
+
try {
|
|
354
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
355
|
+
} catch (e) {
|
|
356
|
+
payload = {};
|
|
357
|
+
}
|
|
358
|
+
const projectDir = resolveProjectDir(payload);
|
|
359
|
+
const event = payload.hook_event_name || "SessionStart";
|
|
360
|
+
if (event === "PostToolUse") postToolUse(projectDir, payload);
|
|
361
|
+
else sessionStart(projectDir);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
main();
|
|
366
|
+
} catch (e) {
|
|
367
|
+
// Inform-only module: on any failure, inject nothing, write nothing, never block.
|
|
368
|
+
} finally {
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|
|
@@ -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);
|