@nurix/etna 0.13.0 → 0.15.1

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 (49) hide show
  1. package/README.md +31 -10
  2. package/bin/etna.js +58 -1
  3. package/data/harness-index.json +1 -1
  4. package/data/harnesses/code/CLAUDE.md +12 -18
  5. package/data/harnesses/code/agents/brainstorm.md +14 -21
  6. package/data/harnesses/code/agents/change-review.md +6 -6
  7. package/data/harnesses/code/agents/harness-manager.md +51 -11
  8. package/data/harnesses/code/agents/maintenance.md +43 -6
  9. package/data/harnesses/code/agents/performance-review.md +1 -1
  10. package/data/harnesses/code/agents/spec-mode.md +2 -2
  11. package/data/harnesses/code/harness.json +6 -0
  12. package/data/harnesses/code/hooks/hooks.json +41 -0
  13. package/data/harnesses/code/hooks/lib/redact.js +37 -0
  14. package/data/harnesses/code/hooks/post-commit.js +136 -0
  15. package/data/harnesses/code/hooks/post-tool-failure.js +136 -0
  16. package/data/harnesses/code/hooks/session-stop.js +207 -0
  17. package/data/harnesses/code/rules/general.md +1 -1
  18. package/data/harnesses/code/rules/nomenclature.md +1 -1
  19. package/data/harnesses/code/skills/boundary/SKILL.md +22 -4
  20. package/data/harnesses/code/skills/boundary/references/boundary-taxonomy.md +229 -161
  21. package/data/harnesses/code/skills/brainstorm/README.md +76 -227
  22. package/data/harnesses/code/skills/brainstorm/SKILL.md +79 -567
  23. package/data/harnesses/code/skills/brainstorm/harvest.md +26 -0
  24. package/data/harnesses/code/skills/brainstorm/templates/build.md +37 -0
  25. package/data/harnesses/code/skills/brainstorm/templates/decisions.md +12 -25
  26. package/data/harnesses/code/skills/brainstorm/templates/domain-model.md +25 -0
  27. package/data/harnesses/code/skills/brainstorm/templates/non-goals.md +2 -2
  28. package/data/harnesses/code/skills/brainstorm/templates/questions.md +18 -67
  29. package/data/harnesses/code/skills/brainstorm/templates/state.md +5 -5
  30. package/data/harnesses/code/skills/brainstorm/templates/tech.md +23 -0
  31. package/data/harnesses/code/skills/brainstorm/templates/thesis.md +21 -0
  32. package/data/harnesses/code/skills/distiller/SKILL.md +214 -0
  33. package/data/harnesses/code/skills/distiller/references/docs-mode.md +526 -0
  34. package/data/harnesses/code/skills/distiller/references/method.md +166 -0
  35. package/data/harnesses/code/skills/documentation/SKILL.md +1 -1
  36. package/data/harnesses/code/skills/documentation/references/templates.md +78 -5
  37. package/data/harnesses/code/skills/workflow-mapper/SKILL.md +1 -1
  38. package/data/harnesses/kb-agi/CLAUDE.md +1 -1
  39. package/data/harnesses/kb-agi/agents/kb-agi-init.md +2 -2
  40. package/data/harnesses/kb-agi/agents/kb-catch-stale.md +1 -1
  41. package/data/harnesses/kb-agi/agents/kb-find-gaps.md +2 -2
  42. package/data/harnesses/kb-agi/agents/kb-fix-links.md +1 -1
  43. package/data/harnesses/kb-agi/agents/kb-refresh-frontdoor.md +1 -1
  44. package/data/harnesses/kb-agi/agents/kb-refresh-summaries.md +1 -1
  45. package/data/harnesses/kb-agi/agents/kb-sync-metadata.md +1 -1
  46. package/data/harnesses/kb-agi/harness.json +6 -0
  47. package/package.json +1 -1
  48. package/data/harnesses/code/skills/brainstorm/greenfield.md +0 -99
  49. package/data/harnesses/code/skills/brainstorm/templates/inventory.md +0 -44
@@ -3,7 +3,7 @@ name: maintenance
3
3
  description: Docs-vs-code coherence audit. Detects and fixes docs drifting from code — stale docs, broken ownership, routing table drift, unowned source paths. Run weekly or after major changes. Contract violations (code drifting from docs) are handled by the `boundary` skill (`init` mode); pre-implementation impact checks by its `check` mode.
4
4
  tools: Read, Glob, Grep, Edit, Write, Bash
5
5
  model: inherit
6
- plugins: [nurix-harness]
6
+ plugins: [code]
7
7
  ---
8
8
 
9
9
  # Maintenance Agent
@@ -23,21 +23,40 @@ Out of scope (handled by the `boundary` skill):
23
23
  ## Step 1: Build Ownership Map
24
24
 
25
25
  1. Read `docs/structure.md` for the authority map
26
- 2. Glob `docs/features/**/*.md` and extract YAML frontmatter (`owns`, `touches`, `depends_on`)
26
+ 2. Glob `docs/{features,modules/*/features}/**/*.md` and extract YAML frontmatter (`owns`, `touches`, `depends_on`)
27
27
  3. Build a map: glob pattern → owning doc
28
28
 
29
+ **Boundary-first fallback.** If that glob yields **no `owns` frontmatter at all** (a real possibility — a boundary-first repo can carry thousands of LOC, a populated `docs/boundaries/`, and zero feature docs) **but `docs/boundaries/**` exists**, drive this pass's ownership/drift surface off the boundaries instead: each boundary's `Bounds`/`Shape` referents (the modules/paths it names) plus `docs/architecture/**` file-references become the **primary** ownership map for Steps 2–5. Feature-`owns` stays primary the moment any appears — this is a fallback, not a replacement. Don't re-mechanize the named-referent / path checks here; Step 2 already resolves boundary referents and Step 5 already greps architecture paths — this clause just promotes them to primary when feature-owns is empty. **Exempt the pre-code markers:** a referent a boundary marks `planned`/`point*`/`transcribe*` is intentionally not-yet-landed — never flag it absent (that is the greenfield contract, not drift); only a referent whose target *has* landed fires, via Step 2's planned-landed / transcribe-landed checks.
30
+
29
31
  ## Step 2: Validate Ownership
30
32
 
31
33
  For each doc with `owns` frontmatter:
32
34
  - Verify the glob patterns match existing files
33
35
  - Report deleted paths, empty globs, orphaned ownership
34
36
 
37
+ For each code symbol or path a doc explicitly **names** (function/export/route/type):
38
+ - Verify it resolves in the code, independent of git-churn — Step 3's churn gate is blind to churn-free false claims
39
+ - Flag unresolved named referents as drift, phrased as a question (e.g. `docs/x.md:NN names fn() — no static definition found; confirm it is not generated/re-exported before treating as stale`)
40
+ - On codegen/reflection/DI/barrel stacks where static resolution is unreliable, skip — yielding nothing is correct
41
+
42
+ Also resolve **`point`-mode boundary pointers** in `docs/boundaries/**/*.md` — a boundary whose `Bounds`/`Shape` carries a `file:line` pointer (e.g. `self-locating in lib/db.js:14`):
43
+ - Verify the named path still exists; a rename (e.g. `lib/db.js`→`lib/store.js`) leaves a stale pointer that **distill② can never repair — boundary docs are write-protected** — so this pass is where it surfaces, as a question
44
+ - If the boundary carries a literal **`planned`** marker (or "pointer lands at implementation") on a named target **and that target now resolves to real code**, the pointer has gone stale the other way — the code landed but the doc still says planned. Raise it as a question (e.g. `db-schema.md is marked planned but packages/api/src/db/schema.ts now exists — confirm and convert the pointer to file:line`). A barrel/re-export at the named path counts as landed.
45
+ - Same skip rule on unreliable-resolution stacks
46
+
47
+ Also handle **`transcribe`-mode boundaries** — a boundary whose `Shape` transcribes a contract (an event/wire/config shape copied from doctrine) **until** the owning module lands, typically with a checklist line like *"replace this transcription when X lands"*:
48
+ - If **X now exists**, the transcribed shape can have silently diverged from the real one — and a transcription names fields, so the divergence is field-level, not path-level (e.g. an event transcribed as `{ entity, event_kind, … }` against a landed module that uses `eventKind`). Raise it as a question that the transcribed shape be reconciled against the real one — or handed to a proposed `boundary init` run for that file (e.g. `outbox-events.md transcribes event_kind but the landed outbox module uses eventKind — confirm the transcription should be reconciled to the real shape`). Question-phrased, never asserted; never rewrite the boundary doc yourself (write-protected).
49
+
50
+ Also check **`env-var-contract` boundaries** — a boundary whose job is to assert the env contract (typically *"every variable the code reads appears in `.env.example`"*). The named-referent check above covers code symbols and paths but not env vars, which live in a separate canonical file:
51
+ - Extract the `UPPER_SNAKE` tokens the boundary names in its `Shape` (allow a trailing `*` glob, e.g. `MASTER_KEK*` matching `MASTER_KEK`, `MASTER_KEK_ID`). Grep each against the declared canonical `.env`/`.env.example` (the boundary names which file is canonical).
52
+ - Raise any absence as a question (e.g. `env-vars.md names MASTER_KEK* but no MASTER_KEK* appears in .env.example — confirm the contract still holds`). Same skip rule when the canonical file isn't declared or doesn't exist.
53
+
35
54
  ## Step 3: Detect Doc Drift
36
55
 
37
56
  Run `git log --since="2 weeks ago" --name-only --pretty=format:"%h %s"` to find changed source files.
38
57
 
39
58
  For each changed file:
40
- - Match against `owns` patterns → identify the owning doc
59
+ - Match against `owns` patterns from every feature doc — flat and module-local alike (`docs/{features,modules/*/features}/**/*.md`) → identify the owning doc
41
60
  - If no owner exists → flag as unowned
42
61
  - If owner exists → compare last modified dates (`git log -1 --format="%ci" -- <path>`), flag docs where code changed more recently (14-day threshold)
43
62
 
@@ -46,16 +65,18 @@ For each changed file:
46
65
  If `./docs/routing.md` exists:
47
66
 
48
67
  1. For each row, verify the file pattern matches existing files and referenced docs/agents exist
49
- 2. Check for new top-level `src/` directories not covered by any routing entry
68
+ 2. Check for new top-level source directories (the repo's actual source roots — derive them, never assume `src/`) not covered by any routing entry
50
69
  3. Fix drift directly, or backlog if unclear
51
70
 
52
71
  Also flag **unowned paths** — top-level source directories with no owning feature doc (no `owns` glob covers them).
53
72
 
73
+ Also catch a **stale greenfield self-label.** If `routing.md` (or `structure.md`) asserts the repo is greenfield — *"Greenfield. No source code or feature docs exist yet"* or similar — **but a real source tree exists** (the derived source roots from this step's item 2 above, or Step 5's Unowned-code derivation, are non-empty, e.g. a populated `packages/` of real workspaces), the self-label has gone stale: the scaffold filled in but the doc still announces an empty repo. Raise it as a question (e.g. `routing.md says "No source code exists yet" but packages/ holds 5 workspaces — confirm the greenfield label should be retired`).
74
+
54
75
  ## Step 5: Check Architecture & Backlog
55
76
 
56
77
  **Architecture drift:** Grep `docs/architecture/` for file path references. Verify referenced paths still exist. Remove or update stale references.
57
78
 
58
- **Unowned code:** Compare all `src/` top-level directories against the union of all `owns` patterns. Report directories with no feature doc ownership.
79
+ **Unowned code:** Compare the repo's actual top-level source directories (derive them — the top-level dirs that contain code, or the roots named across `owns` globs in `structure.md` / feature frontmatter; do not assume `src/`) against the union of all `owns` patterns from both flat and module-local feature docs (`docs/{features,modules/*/features}/**/*.md`). Report directories with no feature doc ownership.
59
80
 
60
81
  **Backlog health:** Read `docs/history/backlog.md`. Report items older than 30 days. Count pending by priority.
61
82
 
@@ -66,13 +87,29 @@ For each stale doc:
66
87
  - If drift is ambiguous (major refactor, unclear intent) → backlog it
67
88
 
68
89
  For unowned code paths:
69
- - Append: `- **[P2]** No feature doc owns \`src/{module}/\` — discovered by maintenance`
90
+ - Append: `- **[P2]** No feature doc owns \`{dir}/\` — discovered by maintenance` (`{dir}` = the actual unowned source directory)
91
+
92
+ **Declined realignment:** When you propose a realignment and the user **declines or does not act**, never drop the finding and never block. Append it to `docs/history/backlog.md` as `- **[P0–P3]** <finding> — discovered by maintenance`, with priority = the finding's own severity **on the same P0–P3 scale as code items** — never a segregated "compliance" section, never auto-relegated to the lowest tier. Record once: dedupe against existing entries; if already present, leave it. (This is the decline branch — it pairs with dispatching a remediation sub-agent on a yes.)
70
93
 
71
94
  ## Step 7: Report & Update Tracking
72
95
 
73
96
  1. Update `docs/structure.md` Health Log with today's date and summary
74
97
  2. Update `docs/memory/maintenance.md` with last review date, stale docs fixed, unowned paths, backlog items created
75
98
  3. Report findings: stale docs, broken ownership, unowned code, architecture drift, backlog health
99
+ 4. **Ledger-distillation threshold.** Count undigested ledger entries — `docs/memory/ledger/**/*.md` entries newer than the high-water mark in `docs/memory/facts/.digest-state.md` (absent state-file ⇒ every entry counts). If the count is **≥20**, add one line to the report **proposing** a `distiller` `facts` run — propose only, never auto-run, no cron. If the user declines or ignores it, do nothing: the count keeps growing and the proposal reappears next pass — that recurrence *is* the dedupe, so never backlog it.
100
+ 5. **Docs-distillation threshold.** Per target doc, count undigested fact-cards (`invalid_from: null`, the doc's path not in their `digested_into:`) that **touch** it by the structural intersection in `skills/distiller/references/docs-mode.md` Resolution 1 (provenance∩`owns` strong, entities∩declared-ownership supporting — qmd-independent). If **≥10** map to **one** doc, add one line to the report **proposing** a `distiller docs --doc <path>` run on that doc — propose only, never auto-run, no cron. Declined or ignored ⇒ the count keeps growing and the proposal reappears next pass; that recurrence *is* the dedupe, so never backlog the proposal.
101
+
102
+ ## Step 8: Refresh Index (Optional)
103
+
104
+ If the `qmd` binary is available (`command -v qmd`), re-run `qmd embed -c <collection>` once per `{repo}-*` collection (`{repo}` = the repo's directory name; `qmd collection list` shows the set harness-manager registered — skip any absent) so the local docs index reflects the files this pass touched. Scope every run with `-c` — the qmd index is global (`~/.cache/qmd/` holds every repo's collections), so a bare `qmd embed` would re-embed unrelated repos' collections. If `qmd` is absent, skip silently — it's never required.
105
+
106
+ ## Step 9: Check Anchor Drift (Optional)
107
+
108
+ If `qmd` is absent (`command -v qmd` finds nothing), **skip this whole step silently** — it's never required.
109
+
110
+ This catches **doc-area drift** — the subject the doc abstracts moving or vanishing from the indexed corpus. It does **not** catch a pure code rename that leaves the doc's prose unchanged: the anchor query semantically matches the doc's own text, so the result set doesn't shift. Code-symbol drift is Step 2's named-referent check, not this one — the two are complementary, not redundant.
111
+
112
+ Glob `docs/{features,modules/*/features}/**/*.md`, `docs/boundaries/**/*.md`, `docs/architecture/**/*.md`, and `docs/operations/**/*.md` for docs carrying `anchors:` frontmatter. For each anchor query on such a doc, resolve it with `qmd query --format json -c <collection> "<query>"` — repeat `-c` per relevant `{repo}-*` collection (no comma lists; the collection set is what `qmd collection list` shows). If a query resolves to **nothing**, or to a result set that has **radically shifted** away from the area the doc describes, raise it as a drift finding **phrased as a question** — e.g. `docs/x.md anchors "<query>" — resolves to nothing; confirm the area moved before treating the doc as stale` — and ask the user. On **yes**, dispatch the remediation sub-agent (the same yes-branch as Step 6). On **decline or no action**, append it to `docs/history/backlog.md` at the finding's own P0–P3 severity per the **Declined realignment** branch in Step 6 — same scale, deduped, never a gate.
76
113
 
77
114
  ---
78
115
 
@@ -3,7 +3,7 @@ name: performance-review
3
3
  description: Combined dependency + code health audit. Part A audits the dependency supply chain — security CVEs, outdated packages, license compliance, abandoned packages, unwanted transitive dependencies, install size. Part B audits code-level health — bundle size, tree-shaking issues, performance anti-patterns, code-quality flags. Run weekly or before releases. Merges the former `dep-health` and `review` agents so the package tree and the code that uses it are audited together.
4
4
  tools: Read, Glob, Grep, Bash
5
5
  model: inherit
6
- plugins: [nurix-harness]
6
+ plugins: [code]
7
7
  ---
8
8
 
9
9
  # Performance-Review Agent
@@ -5,7 +5,7 @@ tools: Read, Glob, Grep, WebSearch, WebFetch
5
5
  model: inherit
6
6
  skills:
7
7
  - documentation
8
- plugins: [nurix-harness]
8
+ plugins: [code]
9
9
  ---
10
10
 
11
11
  # Spec Mode Agent
@@ -19,7 +19,7 @@ You do NOT write the spec — you return structured research that the main agent
19
19
  First, understand the documentation landscape:
20
20
 
21
21
  1. Read `./docs/README.md` to understand the documentation structure
22
- 2. Read `./docs/features/README.md` to see existing feature specs and where the new spec fits
22
+ 2. If `./docs/features/README.md` exists, read it to see existing feature specs and where the new spec fits; otherwise glob the feature docs directly
23
23
  3. If `./docs/memory/spec-mode.md` exists, read it for feature doc inventory, ownership conflict zones, and lessons from previous research
24
24
  4. Read 1-2 existing specs in `./docs/features/` to calibrate format and depth
25
25
  5. Glob `./docs/{features,modules/*/features}/**/*.md` and read YAML frontmatter (`owns`, `touches`, `depends_on`) from each to build the ownership map.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "code",
3
+ "version": "0.15.1",
4
+ "epoch": 12,
5
+ "digest": "1bdfd15cf1e5"
6
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "hooks": {
3
+ "SessionEnd": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-stop.js\"",
10
+ "async": true
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "PostToolUseFailure": [
16
+ {
17
+ "matcher": "",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-failure.js\"",
22
+ "async": true
23
+ }
24
+ ]
25
+ }
26
+ ],
27
+ "PostToolUse": [
28
+ {
29
+ "matcher": "Bash",
30
+ "if": "Bash(git commit *)",
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-commit.js\"",
35
+ "async": true
36
+ }
37
+ ]
38
+ }
39
+ ]
40
+ }
41
+ }
@@ -0,0 +1,37 @@
1
+ // harness/code/hooks/lib/redact.js — shared privacy filter: redacts secret shapes before ledger append.
2
+
3
+ "use strict";
4
+
5
+ // Patterns ordered most-specific first; each replaces a match with [redacted:{kind}].
6
+ const RULES = [
7
+ // PEM private/public key blocks (multiline).
8
+ { kind: "pem", re: /-----BEGIN[\s\S]*?-----END[^-]*-----/g },
9
+ // OpenAI / Anthropic-style keys (sk-..., sk-ant-...).
10
+ { kind: "api-key", re: /\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b/g },
11
+ // AWS access key IDs.
12
+ { kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
13
+ // Bearer tokens in Authorization context.
14
+ { kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi },
15
+ // password= / passwd= / secret= / token= / api_key= style assignments (= or :).
16
+ {
17
+ kind: "credential",
18
+ re: /\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']?[^\s"'&)\]},;[]{3,}/gi,
19
+ },
20
+ // Generic long hex/base64 token ONLY in a key-value credential context.
21
+ {
22
+ kind: "credential",
23
+ re: /\b(?:key|auth|cred|credential|signature|sig)\b\s*[:=]\s*["']?[A-Za-z0-9+/=_-]{32,}/gi,
24
+ },
25
+ ];
26
+
27
+ // Redact secret shapes in arbitrary text; returns a string with placeholders.
28
+ function redact(text) {
29
+ if (text == null) return "";
30
+ let out = String(text);
31
+ for (const rule of RULES) {
32
+ out = out.replace(rule.re, `[redacted:${rule.kind}]`);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ module.exports = { redact };
@@ -0,0 +1,136 @@
1
+ // harness/code/hooks/post-commit.js — PostToolUse(Bash git commit) hook: append one decision entry with commit hash + message.
2
+
3
+ "use strict";
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { execFileSync } = require("child_process");
8
+ const { redact } = require("./lib/redact.js");
9
+
10
+ // Read all of stdin synchronously; returns "" if nothing is piped.
11
+ function readStdin() {
12
+ try {
13
+ return fs.readFileSync(0, "utf8");
14
+ } catch (e) {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
20
+ function resolveProjectDir(payload) {
21
+ return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
22
+ }
23
+
24
+ // Run a git command in projectDir; returns trimmed stdout or fallback on any error.
25
+ function git(projectDir, args, fallback) {
26
+ try {
27
+ return execFileSync("git", ["-C", projectDir, ...args], {
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "ignore"],
30
+ }).trim() || fallback;
31
+ } catch (e) {
32
+ return fallback;
33
+ }
34
+ }
35
+
36
+ // Slug a raw actor name into kebab-case ASCII.
37
+ function slugActor(name) {
38
+ return String(name || "")
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, "-")
41
+ .replace(/^-+|-+$/g, "");
42
+ }
43
+
44
+ // Determine the actor: git user.name (slugged) → $USER → "agent".
45
+ function resolveActor(projectDir) {
46
+ const fromGit = slugActor(git(projectDir, ["config", "user.name"], ""));
47
+ if (fromGit) return fromGit;
48
+ const fromEnv = slugActor(process.env.USER || "");
49
+ if (fromEnv) return fromEnv;
50
+ return "agent";
51
+ }
52
+
53
+ // Truncate a single line to n chars with an ellipsis marker.
54
+ function truncate(text, n) {
55
+ const clean = String(text || "").replace(/\s+/g, " ").trim();
56
+ return clean.length > n ? clean.slice(0, n) + "…" : clean;
57
+ }
58
+
59
+ // Compute the ledger day-file path for a given actor and date.
60
+ function ledgerPath(projectDir, date, actor) {
61
+ const dir = path.join(projectDir, "docs", "memory", "ledger");
62
+ return { dir, file: path.join(dir, `${date}--${actor}.md`) };
63
+ }
64
+
65
+ // Build the day-file header block.
66
+ function header(date, actor) {
67
+ return `# Ledger — ${date} — ${actor}\nAppend-only event ledger. Raw input to distillation — see docs/memory/.\n`;
68
+ }
69
+
70
+ // Pull the bash command string from a PostToolUse payload.
71
+ function commandFromPayload(payload) {
72
+ return (payload && payload.tool_input && payload.tool_input.command) || "";
73
+ }
74
+
75
+ // True only for an actual `git commit` invocation (start of command or after ;, &&, ||, |) —
76
+ // never for commands that merely mention the words (greps, echoes, heredoc prose).
77
+ function isGitCommitInvocation(command) {
78
+ return /(^\s*|[;&|]\s*)git\s+(?:-[^\s]+\s+|-C\s+\S+\s+|\S+=\S+\s+)*commit\b/.test(command);
79
+ }
80
+
81
+ // Main: if the tool was a git commit, append one decision entry with the new commit hash + subject.
82
+ function main() {
83
+ const raw = readStdin();
84
+ let payload = {};
85
+ try {
86
+ payload = raw ? JSON.parse(raw) : {};
87
+ } catch (e) {
88
+ payload = {};
89
+ }
90
+
91
+ if (!isGitCommitInvocation(commandFromPayload(payload))) return;
92
+ if (payload.tool_response && payload.tool_response.is_error === true) return;
93
+
94
+ const projectDir = resolveProjectDir(payload);
95
+ const actor = resolveActor(projectDir);
96
+ const repo = path.basename(projectDir);
97
+ const branch = git(projectDir, ["rev-parse", "--abbrev-ref", "HEAD"], "-");
98
+
99
+ const headLine = git(projectDir, ["log", "-1", "--format=%h%x20%s"], "");
100
+ if (!headLine) return;
101
+ const spaceIdx = headLine.indexOf(" ");
102
+ const hash = spaceIdx > 0 ? headLine.slice(0, spaceIdx) : headLine;
103
+ const subject = spaceIdx > 0 ? headLine.slice(spaceIdx + 1) : "";
104
+
105
+ const now = new Date();
106
+ const pad = (n) => String(n).padStart(2, "0");
107
+ const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
108
+ const hhmm = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
109
+
110
+ const lines = [
111
+ `## ${hhmm} · ${actor} · ${repo}@${branch}`,
112
+ `- type: decision`,
113
+ `- what: committed — ${truncate(redact(subject), 200) || "(no subject)"}`,
114
+ `- refs: commit ${redact(hash)}${payload.session_id ? ` · session ${redact(payload.session_id)}` : ""}`,
115
+ ];
116
+
117
+ const { dir, file } = ledgerPath(projectDir, date, actor);
118
+ const exists = fs.existsSync(file);
119
+ // Dedupe: the same commit hash is never logged twice in one day-file.
120
+ if (exists && fs.readFileSync(file, "utf8").includes(`commit ${hash}`)) return;
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ const block = (exists ? "\n" : header(date, actor) + "\n") + lines.join("\n") + "\n";
123
+ fs.appendFileSync(file, block, "utf8");
124
+ }
125
+
126
+ try {
127
+ main();
128
+ } catch (e) {
129
+ try {
130
+ process.stderr.write(`post-commit hook: ${e && e.message ? e.message : e}\n`);
131
+ } catch (e2) {
132
+ // Nothing more we can do.
133
+ }
134
+ } finally {
135
+ process.exit(0);
136
+ }
@@ -0,0 +1,136 @@
1
+ // harness/code/hooks/post-tool-failure.js — PostToolUseFailure hook: append one high-surprisal failure entry.
2
+
3
+ "use strict";
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { execFileSync } = require("child_process");
8
+ const { redact } = require("./lib/redact.js");
9
+
10
+ // Read all of stdin synchronously; returns "" if nothing is piped.
11
+ function readStdin() {
12
+ try {
13
+ return fs.readFileSync(0, "utf8");
14
+ } catch (e) {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
20
+ function resolveProjectDir(payload) {
21
+ return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
22
+ }
23
+
24
+ // Run a git command in projectDir; returns trimmed stdout or fallback on any error.
25
+ function git(projectDir, args, fallback) {
26
+ try {
27
+ return execFileSync("git", ["-C", projectDir, ...args], {
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "ignore"],
30
+ }).trim() || fallback;
31
+ } catch (e) {
32
+ return fallback;
33
+ }
34
+ }
35
+
36
+ // Slug a raw actor name into kebab-case ASCII.
37
+ function slugActor(name) {
38
+ return String(name || "")
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, "-")
41
+ .replace(/^-+|-+$/g, "");
42
+ }
43
+
44
+ // Determine the actor: git user.name (slugged) → $USER → "agent".
45
+ function resolveActor(projectDir) {
46
+ const fromGit = slugActor(git(projectDir, ["config", "user.name"], ""));
47
+ if (fromGit) return fromGit;
48
+ const fromEnv = slugActor(process.env.USER || "");
49
+ if (fromEnv) return fromEnv;
50
+ return "agent";
51
+ }
52
+
53
+ // Truncate a single line to n chars with an ellipsis marker.
54
+ function truncate(text, n) {
55
+ const clean = String(text || "").replace(/\s+/g, " ").trim();
56
+ return clean.length > n ? clean.slice(0, n) + "…" : clean;
57
+ }
58
+
59
+ // Compute the ledger day-file path for a given actor and date.
60
+ function ledgerPath(projectDir, date, actor) {
61
+ const dir = path.join(projectDir, "docs", "memory", "ledger");
62
+ return { dir, file: path.join(dir, `${date}--${actor}.md`) };
63
+ }
64
+
65
+ // Build the day-file header block.
66
+ function header(date, actor) {
67
+ return `# Ledger — ${date} — ${actor}\nAppend-only event ledger. Raw input to distillation — see docs/memory/.\n`;
68
+ }
69
+
70
+ // Take the first non-empty line of an error string.
71
+ function firstLine(text) {
72
+ const lines = String(text || "").split("\n");
73
+ for (const line of lines) {
74
+ if (line.trim()) return line.trim();
75
+ }
76
+ return "";
77
+ }
78
+
79
+ // Main: append one failure entry describing the tool and its error, never blocking.
80
+ function main() {
81
+ const raw = readStdin();
82
+ let payload = {};
83
+ try {
84
+ payload = raw ? JSON.parse(raw) : {};
85
+ } catch (e) {
86
+ payload = {};
87
+ }
88
+
89
+ const errorText = payload.error || (payload.error_details && payload.error_details.message) || "";
90
+ if (!errorText && !payload.tool_name) return;
91
+
92
+ const projectDir = resolveProjectDir(payload);
93
+ const actor = resolveActor(projectDir);
94
+ const repo = path.basename(projectDir);
95
+ const branch = git(projectDir, ["rev-parse", "--abbrev-ref", "HEAD"], "-");
96
+
97
+ const now = new Date();
98
+ const pad = (n) => String(n).padStart(2, "0");
99
+ const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
100
+ const hhmm = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
101
+
102
+ const tool = payload.tool_name || "unknown-tool";
103
+ const detail = truncate(redact(firstLine(errorText)), 200) || "(no error message)";
104
+ const command =
105
+ payload.tool_input && payload.tool_input.command
106
+ ? ` · cmd: ${truncate(redact(payload.tool_input.command), 100)}`
107
+ : "";
108
+
109
+ const lines = [
110
+ `## ${hhmm} · ${actor} · ${repo}@${branch}`,
111
+ `- type: failure`,
112
+ `- what: ${redact(tool)} failed — ${detail}${command}`,
113
+ ];
114
+ const refs = [];
115
+ if (payload.session_id) refs.push(`session ${payload.session_id}`);
116
+ if (payload.tool_use_id) refs.push(`tool ${payload.tool_use_id}`);
117
+ if (refs.length) lines.push(`- refs: ${redact(refs.join(" · "))}`);
118
+
119
+ const { dir, file } = ledgerPath(projectDir, date, actor);
120
+ const exists = fs.existsSync(file);
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ const block = (exists ? "\n" : header(date, actor) + "\n") + lines.join("\n") + "\n";
123
+ fs.appendFileSync(file, block, "utf8");
124
+ }
125
+
126
+ try {
127
+ main();
128
+ } catch (e) {
129
+ try {
130
+ process.stderr.write(`post-tool-failure hook: ${e && e.message ? e.message : e}\n`);
131
+ } catch (e2) {
132
+ // Nothing more we can do.
133
+ }
134
+ } finally {
135
+ process.exit(0);
136
+ }