@nurix/etna 0.15.7 → 0.16.0

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 (86) hide show
  1. package/bin/etna.js +64 -13
  2. package/data/harness-index.json +7 -5
  3. package/data/harnesses/code/CLAUDE.md +3 -7
  4. package/data/harnesses/code/agents/harness-manager.md +4 -4
  5. package/data/harnesses/code/agents/maintenance.md +1 -1
  6. package/data/harnesses/code/harness.json +4 -3
  7. package/data/harnesses/code/hooks/hooks.json +3 -17
  8. package/data/harnesses/code/hooks/prompt-recall.js +157 -0
  9. package/data/harnesses/code/hooks/session-reflect.js +283 -0
  10. package/data/harnesses/code/skills/distiller/SKILL.md +62 -60
  11. package/data/harnesses/code/skills/distiller/references/docs-mode.md +21 -21
  12. package/data/harnesses/code/skills/distiller/references/method.md +41 -41
  13. package/data/harnesses/code/skills/documentation/references/templates.md +20 -27
  14. package/data/harnesses/code-fable/CLAUDE.md +138 -0
  15. package/data/harnesses/code-fable/agents/brainstorm.md +35 -0
  16. package/data/harnesses/code-fable/agents/change-review.md +249 -0
  17. package/data/harnesses/code-fable/agents/harness-manager.md +193 -0
  18. package/data/harnesses/code-fable/agents/maintenance.md +123 -0
  19. package/data/harnesses/code-fable/agents/performance-review.md +218 -0
  20. package/data/harnesses/code-fable/agents/spec-mode.md +56 -0
  21. package/data/harnesses/code-fable/harness.json +7 -0
  22. package/data/harnesses/code-fable/hooks/hooks.json +27 -0
  23. package/data/harnesses/code-fable/hooks/lib/redact.js +37 -0
  24. package/data/harnesses/code-fable/hooks/prompt-recall.js +157 -0
  25. package/data/harnesses/code-fable/hooks/session-reflect.js +283 -0
  26. package/data/harnesses/code-fable/rules/general.md +96 -0
  27. package/data/harnesses/code-fable/rules/nomenclature.md +34 -0
  28. package/data/harnesses/code-fable/skills/boundary/SKILL.md +262 -0
  29. package/data/harnesses/code-fable/skills/boundary/references/boundary-taxonomy.md +734 -0
  30. package/data/harnesses/code-fable/skills/brainstorm/README.md +136 -0
  31. package/data/harnesses/code-fable/skills/brainstorm/SKILL.md +135 -0
  32. package/data/harnesses/code-fable/skills/brainstorm/harvest.md +26 -0
  33. package/data/harnesses/code-fable/skills/brainstorm/templates/backlog.md +39 -0
  34. package/data/harnesses/code-fable/skills/brainstorm/templates/build.md +37 -0
  35. package/data/harnesses/code-fable/skills/brainstorm/templates/decisions.md +29 -0
  36. package/data/harnesses/code-fable/skills/brainstorm/templates/doctrine.md +31 -0
  37. package/data/harnesses/code-fable/skills/brainstorm/templates/domain-model.md +25 -0
  38. package/data/harnesses/code-fable/skills/brainstorm/templates/glossary.md +19 -0
  39. package/data/harnesses/code-fable/skills/brainstorm/templates/milestones.md +43 -0
  40. package/data/harnesses/code-fable/skills/brainstorm/templates/non-goals.md +21 -0
  41. package/data/harnesses/code-fable/skills/brainstorm/templates/plan-doc.md +61 -0
  42. package/data/harnesses/code-fable/skills/brainstorm/templates/questions.md +35 -0
  43. package/data/harnesses/code-fable/skills/brainstorm/templates/references.md +28 -0
  44. package/data/harnesses/code-fable/skills/brainstorm/templates/rejected.md +29 -0
  45. package/data/harnesses/code-fable/skills/brainstorm/templates/state.md +8 -0
  46. package/data/harnesses/code-fable/skills/brainstorm/templates/tech.md +23 -0
  47. package/data/harnesses/code-fable/skills/brainstorm/templates/thesis.md +21 -0
  48. package/data/harnesses/code-fable/skills/distiller/SKILL.md +219 -0
  49. package/data/harnesses/code-fable/skills/distiller/references/docs-mode.md +526 -0
  50. package/data/harnesses/code-fable/skills/distiller/references/method.md +166 -0
  51. package/data/harnesses/code-fable/skills/documentation/SKILL.md +127 -0
  52. package/data/harnesses/code-fable/skills/documentation/references/examples.md +193 -0
  53. package/data/harnesses/code-fable/skills/documentation/references/style-guide.md +288 -0
  54. package/data/harnesses/code-fable/skills/documentation/references/templates.md +942 -0
  55. package/data/harnesses/code-fable/skills/workflow-mapper/SKILL.md +430 -0
  56. package/data/harnesses/code-fable/skills/workflow-mapper/references/patterns.md +214 -0
  57. package/data/harnesses/{re → discover}/CLAUDE.md +48 -33
  58. package/data/harnesses/{re → discover}/agents/api.md +3 -3
  59. package/data/harnesses/{re → discover}/agents/codebase.md +3 -3
  60. package/data/harnesses/{re → discover}/agents/community.md +3 -3
  61. package/data/harnesses/{re → discover}/agents/deployed-client-bundle.md +5 -5
  62. package/data/harnesses/{re → discover}/agents/distribution-artifacts.md +4 -4
  63. package/data/harnesses/{re → discover}/agents/docs.md +3 -3
  64. package/data/harnesses/{re → discover}/agents/infra-backend-fingerprint.md +2 -2
  65. package/data/harnesses/{re → discover}/agents/packages.md +3 -3
  66. package/data/harnesses/{re → discover}/agents/session.md +19 -11
  67. package/data/harnesses/{re → discover}/agents/website.md +3 -3
  68. package/data/harnesses/{re → discover}/agents/wire-capture.md +9 -7
  69. package/data/harnesses/discover/harness.json +7 -0
  70. package/data/harnesses/{re/rules/re-discovery.md → discover/rules/discovery.md} +54 -17
  71. package/data/harnesses/{re/rules/re-evaluation.md → discover/rules/evaluation.md} +12 -1
  72. package/data/harnesses/{re/rules/re-ingestion.md → discover/rules/ingestion.md} +192 -23
  73. package/data/harnesses/discover/rules/self-correction.md +177 -0
  74. package/data/harnesses/discover/rules/tradecraft.md +532 -0
  75. package/data/harnesses/hr/CLAUDE.md +91 -0
  76. package/data/harnesses/hr/harness.json +7 -0
  77. package/data/harnesses/hr/skills/interview-eval/SKILL.md +118 -0
  78. package/data/harnesses/hr/skills/interview-eval/TEMPLATE.md +124 -0
  79. package/data/harnesses/hr/skills/interview-eval/extract_session.py +418 -0
  80. package/data/harnesses/hr/skills/interview-eval/rubric.md +219 -0
  81. package/data/harnesses/kb-agi/harness.json +1 -0
  82. package/package.json +1 -1
  83. package/data/harnesses/code/hooks/post-commit.js +0 -136
  84. package/data/harnesses/code/hooks/post-tool-failure.js +0 -136
  85. package/data/harnesses/code/hooks/session-stop.js +0 -207
  86. package/data/harnesses/re/harness.json +0 -6
package/bin/etna.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // packages/cli/bin/etna.js
3
- // `npx @nurix/etna <plugin-name>` — copy a Nurix harness into the current
3
+ // `npx @nurix/etna <harness-name>` — copy a Nurix harness into the current
4
4
  // directory. Drops the harness's CLAUDE.md at the project root and copies its
5
5
  // skills, agents, rules, and scripts into <project>/.claude/. No marketplace,
6
6
  // no plugin enablement — files live in the project, version-controlled and
7
- // editable per project. Re-run `etna` to refresh.
7
+ // editable per project. Re-run `etna` to refresh. Harnesses are addressed by
8
+ // their harness/<folder> name; several harnesses may share one plugin.
8
9
 
9
10
  import {
10
11
  existsSync,
@@ -15,6 +16,7 @@ import {
15
16
  cpSync,
16
17
  readdirSync,
17
18
  lstatSync,
19
+ rmSync,
18
20
  } from 'node:fs';
19
21
  import { join, dirname, resolve } from 'node:path';
20
22
  import { fileURLToPath } from 'node:url';
@@ -31,11 +33,13 @@ const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
31
33
 
32
34
  function usage() {
33
35
  const idx = loadHarnessIndex();
34
- console.log(`\nUsage: npx @nurix/etna <plugin-name>\n`);
35
- const names = Object.keys(idx.plugins);
36
- if (names.length) {
36
+ console.log(`\nUsage: npx @nurix/etna <harness-name>\n`);
37
+ const entries = Object.entries(idx.harnesses || {});
38
+ if (entries.length) {
37
39
  console.log(`Available harnesses:`);
38
- for (const name of names) console.log(` ${name}`);
40
+ for (const [folder, plugin] of entries) {
41
+ console.log(` ${folder}${plugin && plugin !== folder ? ` (plugin: ${plugin})` : ''}`);
42
+ }
39
43
  }
40
44
  console.log();
41
45
  }
@@ -125,6 +129,44 @@ function migrateFromPluginInstall(targetDir, pluginName) {
125
129
  return removed;
126
130
  }
127
131
 
132
+ // Hook scripts shipped by earlier epochs (≤12, the memory-ledger tier) and since retired.
133
+ // mergeHooks is additive, so an upgrade must explicitly remove both the stale settings.json
134
+ // entries pointing at these and the leftover script files they reference.
135
+ const RETIRED_HOOKS = ['session-stop.js', 'post-tool-failure.js', 'post-commit.js'];
136
+
137
+ // Remove settings.json hook entries and .claude/hooks/ files for retired hook scripts.
138
+ function retireOldHooks(targetDir) {
139
+ const removed = [];
140
+ const settingsPath = join(targetDir, '.claude', 'settings.json');
141
+ if (existsSync(settingsPath)) {
142
+ const settings = readJson(settingsPath);
143
+ const isRetired = (h) =>
144
+ RETIRED_HOOKS.some((name) => (h.command || '').includes(`/hooks/${name}`));
145
+ let pruned = 0;
146
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
147
+ for (const group of groups) {
148
+ const before = (group.hooks || []).length;
149
+ group.hooks = (group.hooks || []).filter((h) => !isRetired(h));
150
+ pruned += before - group.hooks.length;
151
+ }
152
+ settings.hooks[event] = groups.filter((g) => (g.hooks || []).length > 0);
153
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
154
+ }
155
+ if (pruned > 0) {
156
+ writeJson(settingsPath, settings);
157
+ removed.push(`${pruned} retired hook entr${pruned === 1 ? 'y' : 'ies'} in settings.json`);
158
+ }
159
+ }
160
+ for (const name of RETIRED_HOOKS) {
161
+ const file = join(targetDir, '.claude', 'hooks', name);
162
+ if (existsSync(file)) {
163
+ rmSync(file, { force: true });
164
+ removed.push(`stale hook script ${name}`);
165
+ }
166
+ }
167
+ return removed;
168
+ }
169
+
128
170
  // True if an event's matcher-group already carries a hook with this command string.
129
171
  function groupHasCommand(group, command) {
130
172
  return (group.hooks || []).some((h) => h.command === command);
@@ -168,14 +210,15 @@ async function main(args) {
168
210
  process.exit(args.length === 0 ? 1 : 0);
169
211
  }
170
212
 
171
- const pluginName = args[0];
213
+ const harnessName = args[0];
172
214
  const idx = loadHarnessIndex();
173
- const harnessFolder = idx.plugins[pluginName];
174
- if (!harnessFolder) {
215
+ const harnesses = idx.harnesses || {};
216
+ if (!harnesses[harnessName]) {
175
217
  die(
176
- `Unknown harness '${pluginName}'. Available: ${Object.keys(idx.plugins).join(', ') || '(none)'}`,
218
+ `Unknown harness '${harnessName}'. Available: ${Object.keys(harnesses).join(', ') || '(none)'}`,
177
219
  );
178
220
  }
221
+ const harnessFolder = harnessName;
179
222
 
180
223
  const bundledHarnessDir = join(dataDir, 'harnesses', harnessFolder);
181
224
  const bundledClaudeMd = join(bundledHarnessDir, 'CLAUDE.md');
@@ -191,8 +234,11 @@ async function main(args) {
191
234
  const bundledHarnessJson = join(bundledHarnessDir, 'harness.json');
192
235
  const harnessMeta = existsSync(bundledHarnessJson) ? readJson(bundledHarnessJson) : {};
193
236
  const versionTag = harnessMeta.version ? ` v${harnessMeta.version}` : '';
237
+ // The underlying plugin's name — may differ from the harness name, and several
238
+ // harnesses may share it. Used only for legacy marketplace-entry cleanup.
239
+ const pluginName = harnessMeta.name || harnesses[harnessName] || harnessName;
194
240
 
195
- console.log(`\nInstalling ${pluginName}${versionTag} into ${targetDir}`);
241
+ console.log(`\nInstalling ${harnessName}${versionTag} into ${targetDir}`);
196
242
 
197
243
  const claudeBackup = backupIfExists(claudeMdDest);
198
244
  if (claudeBackup) console.log(` backed up existing CLAUDE.md → ${claudeBackup}`);
@@ -230,6 +276,11 @@ async function main(args) {
230
276
  console.log(` installed harness.json → ${harnessJsonDest}${tag ? ` (${tag})` : ''}`);
231
277
  }
232
278
 
279
+ // Retire hooks earlier epochs shipped before merging, so the additive merge
280
+ // never resurrects an entry pointing at a script this bundle no longer carries.
281
+ const retiredHooks = retireOldHooks(targetDir);
282
+ for (const r of retiredHooks) console.log(` removed ${r}`);
283
+
233
284
  // Merge the bundle's hooks.json into the project's settings.json (idempotent).
234
285
  const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
235
286
  if (added > 0) {
@@ -239,9 +290,9 @@ async function main(args) {
239
290
  const migrated = migrateFromPluginInstall(targetDir, pluginName);
240
291
  for (const entry of migrated) console.log(` removed stale ${entry}`);
241
292
 
242
- console.log(`\nDone — ${pluginName}${versionTag} installed: ${installed.join(', ')}.`);
293
+ console.log(`\nDone — ${harnessName}${versionTag} installed: ${installed.join(', ')}.`);
243
294
  console.log(
244
- `Open Claude Code in ${targetDir}. Re-run \`npx @nurix/etna ${pluginName}\` anytime to refresh harness content.\n`,
295
+ `Open Claude Code in ${targetDir}. Re-run \`npx @nurix/etna ${harnessName}\` anytime to refresh harness content.\n`,
245
296
  );
246
297
  }
247
298
 
@@ -1,8 +1,10 @@
1
1
  {
2
- "fingerprint": "ea9d56be1c3e",
3
- "plugins": {
4
- "code": "code",
5
- "kb-agi": "kb-agi",
6
- "re": "re"
2
+ "fingerprint": "19ff4c6bce0e",
3
+ "harnesses": {
4
+ "code": "code-fable",
5
+ "code-fable": "code-fable",
6
+ "discover": "discover",
7
+ "hr": "hr",
8
+ "kb-agi": "kb-agi"
7
9
  }
8
10
  }
@@ -24,9 +24,6 @@ This is the only step that runs **before** the harness exists; everything below
24
24
  - Read **[`docs/memory/`](docs/memory/)** for agent memory files (one per agent)
25
25
  - Ignore **[`README.md`](README.md)** – it's for human reference only
26
26
  - Check **[`package.json`](package.json)** for available scripts. Detect the package manager from the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `package-lock.json` → npm) and use the matching command. Don't hardcode a specific manager.
27
- - Look at similar modules/files and follow existing patterns
28
-
29
- - Check **[`.claude/rules/`](.claude/rules/)** for specialized instructions (automatically loaded into memory)
30
27
 
31
28
  ---
32
29
 
@@ -40,7 +37,7 @@ Three peer modes plus a Completion step — enter wherever the user's thinking a
40
37
  | **Spec** | new feature work | `spec-mode` agent + `documentation` skill | Plan |
41
38
  | **Plan** | a spec is approved | `documentation` skill → `docs/history/plans/YYYY-MM-DD-{slug}.md` | Completion |
42
39
 
43
- On **Completion**: freeze the plan (`Status: Complete`) and flip the feature spec to `Status: Active`, extending it with how-it-works sections; then distil the plan's trail — if the ledger holds entries past the distiller's high-water mark, run `distiller facts` (ledger→fact-cards), then `distiller docs` for the just-completed feature's doc drafts any pending deltas for approval (presented, never auto-written). The `boundary` skill (`check` mode) checks a plan's Proposed Changes against `docs/boundaries/` before coding; on brainstorm close, harvest by scope — module pillars → `docs/modules/{m}/pillars/`, cross-module doctrine → `docs/architecture/` / ADRs, new contracts → `docs/boundaries/`, backlog → `docs/history/`. Open questions block close.
40
+ On **Completion**: freeze the plan (`Status: Complete`) and flip the feature spec to `Status: Active`, extending it with how-it-works sections; then distil the plan's trail — if the memory inbox (`docs/memory/inbox/`) holds entries, run `distiller facts` (inbox→fact-cards), then `distiller docs` for the just-completed feature's doc drafts any pending deltas for approval (presented, never auto-written). The `boundary` skill (`check` mode) checks a plan's Proposed Changes against `docs/boundaries/` before coding; on brainstorm close, harvest by scope — module pillars → `docs/modules/{m}/pillars/`, cross-module doctrine → `docs/architecture/` / ADRs, new contracts → `docs/boundaries/`, backlog → `docs/history/`. Open questions block close.
44
41
 
45
42
  ---
46
43
 
@@ -114,7 +111,8 @@ Unless explicitly requested:
114
111
  ❌ **Compatibility** – Never maintain backward compatibility unless specified.
115
112
  ❌ **Branches & Worktrees** – Never self-create git branches or worktrees. Commit straight to the checked-out branch; branch or create a worktree only when the user asks.
116
113
  ❌ **Protected Files** – Before editing, if `docs/operations/protected-files.md` exists, read it and never modify any path it lists; also never modify `AGENTS.md` or the `protected-files.md` file itself.
117
- ✅ **Browser Automation** – Use Chrome CDP / "Claude in Chrome" tools whenever they help (verifying UI, reproducing a flow, seeding or inspecting local data) — no need to ask first.
114
+ ✅ **Browser Automation** – Use Chrome CDP / "Claude in Chrome" tools whenever they help (verifying UI, reproducing a flow, seeding or inspecting local data) — no need to ask first. **Main session only:** never drive the browser from a sub-agent or workflow executor — the session-bound Chrome connection destabilizes the sub-agent. A sub-agent that needs browser verification instead returns an explicit CDP checklist (exact URLs, actions, expected outcomes) for the orchestrator to execute in the main session.
115
+ ✅ **Orchestrate via Sub-agents** – Run the main session as an orchestrator: delegate substantive work (research, implementation, audits) to sub-agents spawned via the Agent/Task tool with `model: opus` (Claude Opus), keeping the main context for dispatch, coordination, and synthesis. Give each sub-agent a self-contained prompt; parallelize independent dispatches.
118
116
  ✅ **Documentation** – Keep `docs/` updated when code changes affect documented behavior.
119
117
  ⚠️ **Plans** – Plans are created directly in `docs/history/plans/` (see Workflow → Plan Mode). Once `Status: Complete` or `Status: Abandoned`, the file is frozen — never edited. If scope changes after completion, write a new dated plan that links back to the old one.
120
118
  ⚠️ **Tool Issues** – If any CLI tool, shell alias, or MCP tool behaves unexpectedly (syntax errors, silent failures, wrong defaults), report the exact tool name, command, and error to the user so they can fix or remove the underlying library.
@@ -127,8 +125,6 @@ Unless explicitly requested:
127
125
  `// src/modules/example.js - Module for handling user authentication`
128
126
  - Add a 1-line function description as a comment above each function:
129
127
  `// Extract JWT token from request based on endpoint type`
130
- - Keep related code together in a logical hierarchy.
131
- - No inline comments that restate obvious code. No commented-out code. If a block of code needs a comment to be understandable, refactor for clarity instead.
132
128
 
133
129
  ---
134
130
 
@@ -104,9 +104,9 @@ Table: `| File | Action | From | To | Reason |` (actions: `Move`, `Merge into {t
104
104
 
105
105
  Create the folder structure; move/fold/merge/delete per the plan; add `Status:` to plan files; add ownership frontmatter to feature docs (call the `documentation` skill's **frontmatter derivation** for `owns`/`touches`/`depends_on`); merge scattered backlogs into `docs/history/backlog.md`; fix links broken by moves. Scaffold missing mandated files **by calling the `documentation` skill for each template** — you orchestrate, the skill supplies templates:
106
106
 
107
- - `docs/structure.md` (with `harness-version`), `docs/history/CHANGELOG.md`, `docs/history/backlog.md`, `docs/memory/index.md`, `docs/memory/main.md`, `docs/memory/facts/.digest-state.md` (placeholder, "no entries digested yet"), and the repo-root `README.md` (filled from the real lockfile/scripts/layout — **never overwrite an existing root README**).
107
+ - `docs/structure.md` (with `harness-version`), `docs/history/CHANGELOG.md`, `docs/history/backlog.md`, `docs/memory/index.md`, `docs/memory/main.md`, and the repo-root `README.md` (filled from the real lockfile/scripts/layout — **never overwrite an existing root README**).
108
108
 
109
- Scaffold `docs/memory/facts/` so the distilled tier has a home from day one: hooks write the raw ledger to `docs/memory/ledger/` (not a scaffold — created mechanically on first event), and the `distiller` digests it down into fact-cards under `docs/memory/facts/`. Without the facts dir + its `.digest-state.md` high-water mark, a freshly-baselined consumer accrues a ledger firehose the distiller has nowhere to land — so seed the empty distilled tier alongside the hook-written ledger tier.
109
+ Scaffold `docs/memory/facts/` so the distilled tier has a home from day one: the `session-reflect` hook appends session lessons to the per-actor inbox at `docs/memory/inbox/{actor}.md` (not a scaffold — created mechanically on first reflection), and the `distiller` digests that queue down into fact-cards under `docs/memory/facts/`, consuming the entries it digests (no high-water mark, no `.digest-state.md`). Without the facts dir, a freshly-baselined consumer accrues inbox entries the distiller has nowhere to land — so seed the empty distilled tier alongside the hook-written inbox tier.
110
110
 
111
111
  ### Step 5 — Stamp the version
112
112
 
@@ -116,9 +116,9 @@ Write `harness-version: <installed epoch>` into `docs/structure.md` frontmatter.
116
116
 
117
117
  Strictly optional and guarded on the binary — if `command -v qmd` finds nothing, **skip this entire step silently** (no error, no behavior change) and carry one advisory line into the Step 6 hand-back: *"qmd not installed — semantic retrieval unavailable; glob routing active."* The index is a **local rebuildable lens** — data stays in git; never commit index artifacts, and never use `qmd init` (that writes a project-local `.qmd`; the default global index keeps the repo clean).
118
118
 
119
- If the binary exists, register the docs subtrees once the tree is scaffolded. **Do not register one collection rooted at `docs/`** — qmd 2.5.3 hard-codes the `**/*.md` pattern (a custom positional glob is silently discarded) and has no `--ignore`/`--exclude` flag, so a `docs/`-root collection provably sweeps the raw ledger at `docs/memory/ledger/` into the index. The raw ledger is **not** in scope (it is distilled into `memory/facts`, which is). Register one collection **per indexable subtree** instead — never `docs/memory` as a whole, only `docs/memory/facts` — so the ledger is excluded by construction:
119
+ If the binary exists, register the docs subtrees once the tree is scaffolded. **Do not register one collection rooted at `docs/`** — qmd 2.5.3 hard-codes the `**/*.md` pattern (a custom positional glob is silently discarded) and has no `--ignore`/`--exclude` flag, so a `docs/`-root collection provably sweeps the consumable inbox queue at `docs/memory/inbox/` into the index. The inbox is **not** in scope (it is distilled into `memory/facts`, which is — and its entries are deleted on digestion, which would leave dangling index entries). Register one collection **per indexable subtree** instead — never `docs/memory` as a whole, only `docs/memory/facts` — so the inbox is excluded by construction:
120
120
 
121
- 1. **Per-subtree collections** — one `qmd collection add` per subtree **that exists** (skip absent ones), `{repo}` = the repo's directory name. Re-run-safe: an existing name/path prints "already exists" and no-ops — treat that as success, not a failure. **Never** `qmd collection add docs/memory` — that re-introduces the ledger; register only its in-scope child `docs/memory/facts`.
121
+ 1. **Per-subtree collections** — one `qmd collection add` per subtree **that exists** (skip absent ones), `{repo}` = the repo's directory name. Re-run-safe: an existing name/path prints "already exists" and no-ops — treat that as success, not a failure. **Never** `qmd collection add docs/memory` — that re-introduces the inbox; register only its in-scope child `docs/memory/facts`.
122
122
  - `qmd collection add docs/features --name {repo}-features`
123
123
  - `qmd collection add docs/boundaries --name {repo}-boundaries`
124
124
  - `qmd collection add docs/architecture --name {repo}-architecture`
@@ -95,7 +95,7 @@ For unowned code paths:
95
95
  1. Update `docs/structure.md` Health Log with today's date and summary
96
96
  2. Update `docs/memory/maintenance.md` with last review date, stale docs fixed, unowned paths, backlog items created
97
97
  3. Report findings: stale docs, broken ownership, unowned code, architecture drift, backlog health
98
- 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.
98
+ 4. **Inbox-distillation threshold.** Count undigested inbox entries — `## ` entry blocks across `docs/memory/inbox/*.md` (the inbox is consume-on-digest, so every block present is undigested). If the count is **≥5**, 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.
99
99
  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.
100
100
 
101
101
  ## Step 8: Refresh Index (Optional)
@@ -1,6 +1,7 @@
1
1
  {
2
- "name": "code",
2
+ "harness": "code",
3
+ "name": "code-fable",
3
4
  "version": "0.15.3",
4
- "epoch": 12,
5
- "digest": "8bd2f1540f40"
5
+ "epoch": 13,
6
+ "digest": "c7bce77bd71f"
6
7
  }
@@ -6,33 +6,19 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-stop.js\"",
9
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-reflect.js\"",
10
10
  "async": true
11
11
  }
12
12
  ]
13
13
  }
14
14
  ],
15
- "PostToolUseFailure": [
15
+ "UserPromptSubmit": [
16
16
  {
17
17
  "matcher": "",
18
18
  "hooks": [
19
19
  {
20
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
21
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/prompt-recall.js\""
36
22
  }
37
23
  ]
38
24
  }
@@ -0,0 +1,157 @@
1
+ // plugins/code/hooks/prompt-recall.js — UserPromptSubmit hook: surface fact-cards relevant to the prompt via qmd BM25; stdout is injected as context.
2
+
3
+ "use strict";
4
+
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const { execFileSync } = require("child_process");
9
+
10
+ const MIN_PROMPT_CHARS = 24;
11
+ const MIN_SCORE = 0.45;
12
+ const MAX_CARDS = 3;
13
+ const MAX_STATEMENT_CHARS = 300;
14
+ const MAX_KEYWORDS = 8;
15
+
16
+ const STOPWORDS = new Set(
17
+ "a an and are as at be but by can could do does for from has have how i in is it its me my of on or our should so that the their then there these this to was we what when where which who why will with would you your".split(
18
+ " ",
19
+ ),
20
+ );
21
+
22
+ // Read all of stdin synchronously; returns "" if nothing is piped.
23
+ function readStdin() {
24
+ try {
25
+ return fs.readFileSync(0, "utf8");
26
+ } catch (e) {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
32
+ function resolveProjectDir(payload) {
33
+ return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
34
+ }
35
+
36
+ // Map a qmd://collection/relpath hit URI to the card file under docs/memory/facts/.
37
+ function cardPath(factsDir, fileUri) {
38
+ return path.join(factsDir, String(fileUri || "").replace(/^qmd:\/\/[^/]+\//, ""));
39
+ }
40
+
41
+ // A card's atomic statement: the body after frontmatter, whitespace-flattened and clipped.
42
+ function cardStatement(file) {
43
+ try {
44
+ const body = fs
45
+ .readFileSync(file, "utf8")
46
+ .replace(/^---\n[\s\S]*?\n---\n?/, "")
47
+ .replace(/\s+/g, " ")
48
+ .trim();
49
+ return body.length > MAX_STATEMENT_CHARS ? body.slice(0, MAX_STATEMENT_CHARS) + "…" : body;
50
+ } catch (e) {
51
+ return "";
52
+ }
53
+ }
54
+
55
+ // Run one qmd search mode against the facts collection; [] on any failure — recall is strictly optional.
56
+ function qmdHits(mode, query, collection) {
57
+ try {
58
+ const out = JSON.parse(
59
+ execFileSync("qmd", [mode, query, "--format", "json", "-n", String(MAX_CARDS + 2), "-c", collection], {
60
+ encoding: "utf8",
61
+ timeout: 10000,
62
+ stdio: ["ignore", "pipe", "ignore"],
63
+ }),
64
+ );
65
+ return Array.isArray(out) ? out : [];
66
+ } catch (e) {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ // Main: search the facts collection for the prompt and emit unseen high-score cards.
72
+ function main() {
73
+ if (process.env.ETNA_REFLECT) return;
74
+
75
+ const raw = readStdin();
76
+ let payload = {};
77
+ try {
78
+ payload = raw ? JSON.parse(raw) : {};
79
+ } catch (e) {
80
+ payload = {};
81
+ }
82
+
83
+ const prompt = String(payload.prompt || "").trim();
84
+ if (!prompt || prompt.startsWith("/") || prompt.length < MIN_PROMPT_CHARS) return;
85
+
86
+ const projectDir = resolveProjectDir(payload);
87
+ const factsDir = path.join(projectDir, "docs", "memory", "facts");
88
+ let hasCards = false;
89
+ try {
90
+ hasCards = fs.readdirSync(factsDir).some((f) => f.endsWith(".md"));
91
+ } catch (e) {
92
+ return;
93
+ }
94
+ if (!hasCards) return;
95
+
96
+ // BM25 keywords first (fast, strict-AND), vector similarity as the natural-language
97
+ // fallback (~1s warm) — `qmd query` (LLM expansion + rerank) is too slow to block a prompt.
98
+ const collection = `${path.basename(projectDir)}-facts`;
99
+ const keywords = Array.from(
100
+ new Set(
101
+ prompt
102
+ .toLowerCase()
103
+ .replace(/[^a-z0-9\s_./-]/g, " ")
104
+ .split(/\s+/)
105
+ .filter((w) => w.length > 2 && !STOPWORDS.has(w)),
106
+ ),
107
+ )
108
+ .slice(0, MAX_KEYWORDS)
109
+ .join(" ");
110
+ let hits = keywords ? qmdHits("search", keywords, collection) : [];
111
+ if (!hits.length) hits = qmdHits("vsearch", prompt.replace(/\s+/g, " ").slice(0, 300), collection);
112
+ if (!hits.length) return;
113
+
114
+ // Inject each card at most once per session.
115
+ const sessionKey = String(payload.session_id || "any").replace(/[^a-zA-Z0-9-]/g, "");
116
+ const stateFile = path.join(os.tmpdir(), `etna-recall-${sessionKey}.txt`);
117
+ let seen = [];
118
+ try {
119
+ seen = fs.readFileSync(stateFile, "utf8").split("\n").filter(Boolean);
120
+ } catch (e) {
121
+ seen = [];
122
+ }
123
+
124
+ const fresh = hits
125
+ .filter((h) => h && h.score >= MIN_SCORE && h.file && !seen.includes(h.file))
126
+ .slice(0, MAX_CARDS);
127
+ const lines = [];
128
+ for (const hit of fresh) {
129
+ const statement = cardStatement(cardPath(factsDir, hit.file));
130
+ if (statement) {
131
+ lines.push(`- ${statement} [${String(hit.file).replace(/^qmd:\/\/[^/]+\//, "docs/memory/facts/")}]`);
132
+ }
133
+ }
134
+ if (!lines.length) return;
135
+
136
+ try {
137
+ fs.appendFileSync(stateFile, fresh.map((h) => h.file).join("\n") + "\n", "utf8");
138
+ } catch (e) {
139
+ // Dedupe state is best-effort; a re-injection is harmless.
140
+ }
141
+
142
+ process.stdout.write(
143
+ `<memory-recall>\nProject memory possibly relevant to this prompt (distilled fact-cards):\n${lines.join("\n")}\n</memory-recall>\n`,
144
+ );
145
+ }
146
+
147
+ try {
148
+ main();
149
+ } catch (e) {
150
+ try {
151
+ process.stderr.write(`prompt-recall hook: ${e && e.message ? e.message : e}\n`);
152
+ } catch (e2) {
153
+ // Nothing more we can do.
154
+ }
155
+ } finally {
156
+ process.exit(0);
157
+ }