@nurix/etna 0.18.15 → 0.18.16

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 (51) hide show
  1. package/README.md +11 -16
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/code/CLAUDE.md +71 -57
  4. package/data/harnesses/code/agents/{spec-mode.md → lld-mode.md} +12 -12
  5. package/data/harnesses/code/agents/maintenance.md +2 -31
  6. package/data/harnesses/code/harness.json +2 -2
  7. package/data/harnesses/code/hooks/hooks.json +0 -23
  8. package/data/harnesses/code/rules/testing.md +25 -0
  9. package/data/harnesses/code/skills/brainstorm/SKILL.md +1 -1
  10. package/data/harnesses/code/skills/brainstorm/exit-artifacts.md +2 -2
  11. package/data/harnesses/code/skills/documentation/SKILL.md +2 -2
  12. package/data/harnesses/code/skills/documentation/references/templates.md +10 -77
  13. package/data/harnesses/etna/CLAUDE.md +11 -12
  14. package/data/harnesses/etna/agents/{spec-mode.md → lld-mode.md} +12 -12
  15. package/data/harnesses/etna/agents/maintenance.md +2 -31
  16. package/data/harnesses/etna/harness.json +2 -2
  17. package/data/harnesses/etna/hooks/hooks.json +0 -23
  18. package/data/harnesses/etna/rules/testing.md +25 -0
  19. package/data/harnesses/etna/skills/brainstorm/SKILL.md +1 -1
  20. package/data/harnesses/etna/skills/brainstorm/exit-artifacts.md +2 -2
  21. package/data/harnesses/etna/skills/documentation/SKILL.md +2 -2
  22. package/data/harnesses/etna/skills/documentation/references/templates.md +10 -77
  23. package/data/harnesses/mobile/CLAUDE.md +78 -55
  24. package/data/harnesses/mobile/agents/{spec-mode.md → lld-mode.md} +12 -12
  25. package/data/harnesses/mobile/agents/maintenance.md +2 -31
  26. package/data/harnesses/mobile/harness.json +2 -2
  27. package/data/harnesses/mobile/hooks/hooks.json +0 -23
  28. package/data/harnesses/mobile/rules/testing.md +25 -0
  29. package/data/harnesses/mobile/skills/brainstorm/SKILL.md +1 -1
  30. package/data/harnesses/mobile/skills/brainstorm/exit-artifacts.md +2 -2
  31. package/data/harnesses/mobile/skills/documentation/SKILL.md +2 -2
  32. package/data/harnesses/mobile/skills/documentation/references/templates.md +10 -77
  33. package/package.json +1 -1
  34. package/data/harnesses/code/hooks/lib/redact.mjs +0 -35
  35. package/data/harnesses/code/hooks/prompt-recall.mjs +0 -155
  36. package/data/harnesses/code/hooks/session-reflect.mjs +0 -285
  37. package/data/harnesses/code/skills/distiller/SKILL.md +0 -90
  38. package/data/harnesses/code/skills/distiller/references/docs-mode.md +0 -299
  39. package/data/harnesses/code/skills/distiller/references/method.md +0 -94
  40. package/data/harnesses/etna/hooks/lib/redact.mjs +0 -35
  41. package/data/harnesses/etna/hooks/prompt-recall.mjs +0 -155
  42. package/data/harnesses/etna/hooks/session-reflect.mjs +0 -285
  43. package/data/harnesses/etna/skills/distiller/SKILL.md +0 -90
  44. package/data/harnesses/etna/skills/distiller/references/docs-mode.md +0 -299
  45. package/data/harnesses/etna/skills/distiller/references/method.md +0 -94
  46. package/data/harnesses/mobile/hooks/lib/redact.mjs +0 -35
  47. package/data/harnesses/mobile/hooks/prompt-recall.mjs +0 -155
  48. package/data/harnesses/mobile/hooks/session-reflect.mjs +0 -285
  49. package/data/harnesses/mobile/skills/distiller/SKILL.md +0 -90
  50. package/data/harnesses/mobile/skills/distiller/references/docs-mode.md +0 -299
  51. package/data/harnesses/mobile/skills/distiller/references/method.md +0 -94
@@ -1,285 +0,0 @@
1
- // plugins/code/hooks/session-reflect.mjs — SessionEnd hook: reflect the session into durable lessons (headless haiku pass) appended to the actor's memory inbox.
2
-
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { execFileSync, spawn } from "node:child_process";
7
- import { fileURLToPath } from "node:url";
8
- import { redact } from "./lib/redact.mjs";
9
-
10
- // ESM has no __filename; derive it from this module's URL for the detached worker re-spawn.
11
- const __filename = fileURLToPath(import.meta.url);
12
-
13
- // A session below all three thresholds carries no lesson worth a model call.
14
- const MIN_TURNS = 8;
15
- const MAX_MESSAGE_CHARS = 1500;
16
- const MAX_DIGEST_CHARS = 80000;
17
- const MAX_LESSONS = 6;
18
-
19
- // Read all of stdin synchronously; returns "" if nothing is piped.
20
- function readStdin() {
21
- try {
22
- return fs.readFileSync(0, "utf8");
23
- } catch (e) {
24
- return "";
25
- }
26
- }
27
-
28
- // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
29
- function resolveProjectDir(payload) {
30
- return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
31
- }
32
-
33
- // Run a git command in projectDir; returns trimmed stdout or fallback on any error.
34
- function git(projectDir, args, fallback) {
35
- try {
36
- return execFileSync("git", ["-C", projectDir, ...args], {
37
- encoding: "utf8",
38
- stdio: ["ignore", "pipe", "ignore"],
39
- }).trim() || fallback;
40
- } catch (e) {
41
- return fallback;
42
- }
43
- }
44
-
45
- // Slug a raw actor name into kebab-case ASCII.
46
- function slugActor(name) {
47
- return String(name || "")
48
- .toLowerCase()
49
- .replace(/[^a-z0-9]+/g, "-")
50
- .replace(/^-+|-+$/g, "");
51
- }
52
-
53
- // Determine the actor: git user.name (slugged) → $USER → "agent".
54
- function resolveActor(projectDir) {
55
- const fromGit = slugActor(git(projectDir, ["config", "user.name"], ""));
56
- if (fromGit) return fromGit;
57
- const fromEnv = slugActor(process.env.USER || "");
58
- if (fromEnv) return fromEnv;
59
- return "agent";
60
- }
61
-
62
- // Truncate a single line to n chars with an ellipsis marker.
63
- function clip(text, n) {
64
- const clean = String(text || "").replace(/\s+/g, " ").trim();
65
- return clean.length > n ? clean.slice(0, n) + "…" : clean;
66
- }
67
-
68
- // Extract plain text from a message whose content is a string or content-block array.
69
- function extractText(msg) {
70
- if (!msg || !msg.content) return "";
71
- if (typeof msg.content === "string") return msg.content;
72
- if (Array.isArray(msg.content)) {
73
- return msg.content
74
- .filter((b) => b && b.type === "text" && typeof b.text === "string")
75
- .map((b) => b.text)
76
- .join(" ");
77
- }
78
- return "";
79
- }
80
-
81
- // Strip harness-injected wrappers so only what the user actually typed survives.
82
- function stripInjected(text) {
83
- return String(text || "")
84
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
85
- .replace(/<(local-command-caveat|local-command-stdout|local-command-stderr|command-name|command-message|command-args|command-contents)>[\s\S]*?<\/\1>/g, "")
86
- .trim();
87
- }
88
-
89
- // Scan an assistant message's tool_use blocks for edited files and git commits.
90
- function collectToolUse(msg, summary) {
91
- if (!msg || !Array.isArray(msg.content)) return;
92
- for (const block of msg.content) {
93
- if (!block || block.type !== "tool_use") continue;
94
- const name = block.name || "";
95
- const input = block.input || {};
96
- if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(name) && input.file_path) {
97
- summary.files.add(input.file_path);
98
- }
99
- if (name === "Bash" && typeof input.command === "string" && /\bgit\s+commit\b/.test(input.command)) {
100
- summary.sawCommit = true;
101
- }
102
- }
103
- }
104
-
105
- // Walk the JSONL transcript once: turn/edit/commit counts plus a USER/ASSISTANT text digest.
106
- function digestTranscript(transcriptPath) {
107
- const out = { turns: 0, files: new Set(), sawCommit: false, lines: [] };
108
- if (!transcriptPath || !fs.existsSync(transcriptPath)) return out;
109
- for (const line of fs.readFileSync(transcriptPath, "utf8").split("\n")) {
110
- if (!line.trim()) continue;
111
- let row;
112
- try {
113
- row = JSON.parse(line);
114
- } catch (e) {
115
- continue;
116
- }
117
- if (row.isSidechain) continue;
118
- const msg = row && row.message ? row.message : row;
119
- const role = (msg && msg.role) || row.type;
120
- if (role === "user") {
121
- const text = stripInjected(extractText(msg));
122
- if (!text || text.startsWith("Caveat: The messages below")) continue;
123
- out.lines.push("USER: " + clip(text, MAX_MESSAGE_CHARS));
124
- } else if (role === "assistant") {
125
- out.turns += 1;
126
- collectToolUse(msg, out);
127
- const text = extractText(msg);
128
- if (text) out.lines.push("ASSISTANT: " + clip(text, MAX_MESSAGE_CHARS));
129
- }
130
- }
131
- return out;
132
- }
133
-
134
- // Compute the per-actor inbox file path.
135
- function inboxPath(projectDir, actor) {
136
- const dir = path.join(projectDir, "docs", "memory", "inbox");
137
- return { dir, file: path.join(dir, `${actor}.md`) };
138
- }
139
-
140
- // True when this session already wrote an inbox entry (SessionEnd re-fires on resume).
141
- function alreadyReflected(file, sessionId) {
142
- try {
143
- return Boolean(sessionId) && fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(`session ${sessionId}`);
144
- } catch (e) {
145
- return false;
146
- }
147
- }
148
-
149
- // Build the inbox file header block.
150
- function inboxHeader(actor) {
151
- return `# Memory inbox — ${actor}\nSession lessons awaiting distillation — appended by the session-reflect hook, consumed by \`distiller facts\`.\n`;
152
- }
153
-
154
- // The full output contract sits AFTER the transcript — with it only up front, the model
155
- // continues the conversation in the transcript instead of reflecting on it.
156
- const PROMPT_HEADER = [
157
- "You are a memory reflector. Below is the transcript of a FINISHED coding session,",
158
- "delimited by <transcript> tags. Do not reply to it or continue it — you will be asked",
159
- "to extract durable lessons from it after the closing tag.",
160
- "",
161
- "<transcript>",
162
- "",
163
- ].join("\n");
164
-
165
- const PROMPT_FOOTER = [
166
- "",
167
- "</transcript>",
168
- "",
169
- "The session above is over. Extract ONLY durable lessons a future session in this",
170
- "repository would benefit from knowing:",
171
- "- corrections or preferences the user stated",
172
- "- failures whose root cause was found, with the fix",
173
- "- decisions made, with their rationale",
174
- "- discovered constraints or non-obvious facts about the project or its tooling",
175
- "Rules:",
176
- "- Most sessions contain none. If nothing qualifies, output exactly: NONE",
177
- `- Otherwise output at most ${MAX_LESSONS} bullets and nothing else — no preamble, no headers.`,
178
- '- Each bullet starts with "- [project]" (true of this repo or its tooling) or "- [user]" (true of this user).',
179
- "- Each bullet is one complete, self-contained sentence with names and identifiers spelled out.",
180
- "- Routine work (commits made, files edited, tasks completed) is NOT a lesson. Never include secrets.",
181
- ].join("\n");
182
-
183
- // Hook mode: stash the payload and hand off to a detached worker so session exit is never delayed.
184
- function hook() {
185
- const raw = readStdin();
186
- let payload = {};
187
- try {
188
- payload = raw ? JSON.parse(raw) : {};
189
- } catch (e) {
190
- payload = {};
191
- }
192
- if (!payload.transcript_path) return;
193
-
194
- const stash = {
195
- projectDir: resolveProjectDir(payload),
196
- transcriptPath: payload.transcript_path,
197
- sessionId: payload.session_id || "",
198
- };
199
- const stashFile = path.join(os.tmpdir(), `etna-reflect-${stash.sessionId || process.pid}.json`);
200
- fs.writeFileSync(stashFile, JSON.stringify(stash), "utf8");
201
- spawn(process.execPath, [__filename, "--worker", stashFile], { detached: true, stdio: "ignore" }).unref();
202
- }
203
-
204
- // Worker mode: filter, digest, run the reflection model, append surviving lessons to the inbox.
205
- function worker(stashFile) {
206
- let stash;
207
- try {
208
- stash = JSON.parse(fs.readFileSync(stashFile, "utf8"));
209
- } finally {
210
- try {
211
- fs.rmSync(stashFile, { force: true });
212
- } catch (e) {
213
- // Stale stash files are harmless.
214
- }
215
- }
216
-
217
- const { projectDir, transcriptPath, sessionId } = stash;
218
- const digest = digestTranscript(transcriptPath);
219
- if (digest.turns < MIN_TURNS && digest.files.size === 0 && !digest.sawCommit) return;
220
-
221
- const actor = resolveActor(projectDir);
222
- const { dir, file } = inboxPath(projectDir, actor);
223
- if (alreadyReflected(file, sessionId)) return;
224
-
225
- let text = redact(digest.lines.join("\n"));
226
- if (text.length > MAX_DIGEST_CHARS) {
227
- text = text.slice(0, 8000) + "\n[... middle of session elided ...]\n" + text.slice(-(MAX_DIGEST_CHARS - 8000));
228
- }
229
-
230
- const output = execFileSync("claude", ["-p", "--model", "haiku"], {
231
- input: PROMPT_HEADER + text + PROMPT_FOOTER,
232
- encoding: "utf8",
233
- cwd: os.tmpdir(),
234
- env: Object.assign({}, process.env, { ETNA_REFLECT: "1" }),
235
- timeout: 240000,
236
- maxBuffer: 4 * 1024 * 1024,
237
- stdio: ["pipe", "pipe", "ignore"],
238
- });
239
-
240
- const lessons = output
241
- .split("\n")
242
- .map((l) => l.trim())
243
- .filter((l) => /^- \[(project|user)\]\s+\S/.test(l))
244
- .slice(0, MAX_LESSONS)
245
- .map((l) => redact(l));
246
- if (!lessons.length) return;
247
-
248
- const repo = path.basename(projectDir);
249
- const branch = git(projectDir, ["rev-parse", "--abbrev-ref", "HEAD"], "-");
250
- const now = new Date();
251
- const pad = (n) => String(n).padStart(2, "0");
252
- const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
253
-
254
- fs.mkdirSync(dir, { recursive: true });
255
- if (alreadyReflected(file, sessionId)) return;
256
- const exists = fs.existsSync(file);
257
- const block =
258
- (exists ? "\n" : inboxHeader(actor) + "\n") +
259
- `## ${stamp} · session ${sessionId || "unknown"} · ${repo}@${branch}\n` +
260
- lessons.join("\n") +
261
- "\n";
262
- fs.appendFileSync(file, block, "utf8");
263
- }
264
-
265
- function main() {
266
- if (process.env.ETNA_REFLECT) return;
267
- const workerIdx = process.argv.indexOf("--worker");
268
- if (workerIdx !== -1) {
269
- worker(process.argv[workerIdx + 1]);
270
- } else {
271
- hook();
272
- }
273
- }
274
-
275
- try {
276
- main();
277
- } catch (e) {
278
- try {
279
- process.stderr.write(`session-reflect hook: ${e && e.message ? e.message : e}\n`);
280
- } catch (e2) {
281
- // Nothing more we can do.
282
- }
283
- } finally {
284
- process.exit(0);
285
- }
@@ -1,90 +0,0 @@
1
- ---
2
- name: distiller
3
- description: The one place captured session lessons are distilled into colder, higher-signal knowledge. One skill, two modes — `facts` (memory inbox → atomic provenance-linked fact-cards in `docs/memory/facts/`) and `docs` (fact-cards → proposed, human-approved doc deltas). Both modes read one shared algorithm spec, `references/method.md` (surprisal filter, EDU fact-cards, reconciliation, bi-temporal supersession — all stolen, all cited), so they can never drift apart. Consumes the inbox entries it digests (the inbox is a queue, not an audit log — git history and commit/session provenance are the audit trail); NEVER auto-runs (invoked by the user, or by accepting the maintenance agent's threshold proposal). Triggers on "distill the inbox", "distill facts", "run the distiller", "distill into fact-cards", "facts from the inbox", "distill docs", "draft doc deltas", "distiller docs", "/distiller".
4
- ---
5
-
6
- # Distiller Skill
7
-
8
- The one place a repo's **captured session lessons** are distilled into colder, higher-signal knowledge. The memory inbox (`docs/memory/inbox/{actor}.md`) is a per-actor queue of lessons the `session-reflect` hook extracted at session end; the distiller reconciles the _surprising_ part of it into atomic, self-contained, provenance-linked **fact-cards** (`docs/memory/facts/`) and consumes the entries it digests. Volume falls, signal rises — one hop down the distillation gradient.
9
-
10
- The methods this skill runs are **stolen, not invented** — surprisal-based retention (Nemori), non-lossy EDU fact-cards (EMem), per-fact ADD/UPDATE/ SUPERSEDE/NOOP reconciliation (Mem0), and bi-temporal supersession (Zep/ Graphiti). They live in exactly one place:
11
-
12
- > **Read [`references/method.md`](references/method.md) first, in every mode.** It carries the four mechanisms verbatim-grade (with their arXiv ids — provenance matters here of all places), the fact-card format, and the inbox-consumption protocol. This SKILL.md only orchestrates those mechanisms over the inbox.
13
-
14
- The reason the two modes are one skill: **both read one algorithm root.** Two components would mean two copies of the surprisal/reconciliation/supersession model that drift apart. One skill, one spec, zero drift.
15
-
16
- ---
17
-
18
- ## Picking a mode
19
-
20
- | You want to… | Mode | Ships |
21
- | --- | --- | --- |
22
- | Distill new inbox entries into fact-cards | **`facts`** | now |
23
- | Distill fact-cards into proposed doc deltas | **`docs`** | now — procedure in [`references/docs-mode.md`](references/docs-mode.md) |
24
-
25
- Both modes read [`references/method.md`](references/method.md); each mode's procedure that sequences it lives in its own section below.
26
-
27
- ## Execution model — pure skill, never autonomous
28
-
29
- Runs **in the main session** — no standing sub-agents, no daemon, no cron, no watch. The harness **proposes**, the user **disposes**: this skill runs only when the user asks, when the **Completion flow** invokes it (the kit `CLAUDE.md` Completion step distils the just-finished plan's trail — `facts` if the inbox holds entries, then `docs` which still drafts for approval), or when the user accepts the `maintenance` agent's threshold proposal (≥5 undigested inbox entries). It never schedules itself, never runs on a hook, never gates anything.
30
-
31
- ---
32
-
33
- ## Mode: `facts` (inbox → fact-cards)
34
-
35
- Distill the inbox's undigested entries into fact-cards. The algorithm is in [`references/method.md`](references/method.md) — follow it; this section is the procedure that sequences it.
36
-
37
- 1. **Collect the inbox.** Read every entry block (`## <timestamp> · session <id> · <repo>@<branch>`) across `docs/memory/inbox/*.md`. The inbox is a consume-on-digest queue — what is present is exactly what is undigested, so there is no separate high-water mark. An immediate second run finds an empty inbox and is a clean NOOP.
38
-
39
- **Thin or empty inbox ⇒ not a NOOP — distill the decision record.** A project whose sessions produced no reflected lessons is **not** a project with nothing to distill. Architecture decisions and reversals are high-surprisal by nature — they exist precisely because they were _not_ predictable. So when the inbox is thin/empty, also ingest, as surprisal sources for steps 2–5:
40
- - `docs/architecture/adr/**` — each accepted ADR is a surprising decision (its `Decision` + **Options Considered** — the rejected-alternatives section in the `documentation` §10 ADR template — carry the prediction error).
41
- - `docs/brainstorm/{topic}/` — the kept Brainstorming-Mode design records (`pillars/decisions.md`, `rejected.md`, etc.): the reasoning, reversals, and roads-not-taken behind each design call, retained as committed provenance. The richest surprisal source the harness produces. (Where a repo keeps no brainstorm record in `docs/` — e.g. an older transient-workspace harness — fall back to `docs/memory/brainstorm.md`, the owner pronouncements recorded outside the inbox.) Provenance points at the ADR file / brainstorm pillar, not a session id. These sources are **never consumed** — reconciliation (step 4) is what keeps a re-run over them idempotent.
42
-
43
- 2. **Surprisal filter → survivors** (method §1). Present the LLM with (a) a summary of the _current_ fact-set, (b) the undigested entries. Keep only what is **surprising** relative to (a). Predictable ⇒ dropped — no importance scores, no templates, prediction error is the only retention signal. Inbox entries arrive semi-distilled (the reflection hook already dropped routine work), so the filter's job here is narrower: drop what the existing fact-set already predicts. (First run, empty fact-set: keep decisions, reversals, discovered constraints, and failures with a cause worth remembering.)
44
-
45
- 3. **Decompose survivors → EDU candidates** (method §2). Each survivor becomes **one** atomic, self-contained statement — entity names normalized, minimal context baked into the sentence — with `provenance` pointing at the session ids and commits its inbox entry names. Decomposition, **not** summarization: one survivor may yield several cards; no information is destroyed; everything stays auditable back to its session and commits.
46
-
47
- 4. **Reconcile each candidate against existing cards** (method §3). Query the existing facts for semantic neighbors, then decide **ADD / UPDATE / SUPERSEDE / NOOP** for each. Gate on the **facts collection holding cards**, not on the binary and not on the collection merely being registered — a present qmd whose `{repo}-facts` collection is _registered but empty_ (the common first run: the `maintenance` agent's baseline mode registers + embeds `{repo}-facts` over a freshly-scaffolded `docs/memory/facts/` that has zero cards) returns nothing useful, exactly like an unregistered collection.
48
- - **Cards present** — `command -v qmd` succeeds **and** `docs/memory/facts/` holds at least one card (`docs/memory/facts/*.md` is non-empty): query scoped to the collection — `qmd query --format json -c {repo}-facts "<the candidate statement>"`. `{repo}` = the repo's directory name. Scope with `-c` always. (If the query returns nothing on a non-empty card set, the collection is registered-but- unbuilt — treat that as the fallback trigger below, not as "no neighbors.")
49
- - **No cards to query** (qmd absent, **or** `docs/memory/facts/*.md` empty — the common first-run case where the facts dir was scaffolded but never distilled into): fall back to filename/grep similarity over `docs/memory/facts/*.md` (slug overlap + entity-name grep), and **note in the report** that reconciliation ran without semantic retrieval. Step 7 builds/refreshes the collection at the end of the run, so the _next_ run — with cards now present — reconciles semantically.
50
-
51
- 5. **Write the results** (method §4 — bi-temporal, never delete).
52
- - **ADD** → write a new card `docs/memory/facts/{kebab-slug}.md` in the format.
53
- - **UPDATE** → refine the existing card's statement in place; extend its `provenance`.
54
- - **SUPERSEDE** → on the **old** card set `invalid_from:` (today) and `superseded_by:` (the new fact-id); write the **new** card with `valid_from:` today. The old file stays in place — history stays queryable, nothing vanishes.
55
- - **Self-contained reversal** (one input states X-then-reversed-to-Y, no old card exists yet) → emit **two** cards in one pass: a born-invalid historical card for X (`invalid_from` + `superseded_by` → Y) and a live card for Y. See method.md §4 "Self-contained reversal."
56
- - **NOOP** → the fact is already known; write nothing.
57
-
58
- 6. **Consume the digested entries LAST.** Only after every card is written, delete exactly the entry blocks this run digested from `docs/memory/inbox/*.md` (keep each file's header; never touch undigested blocks). Last, so a crash mid-run leaves the entries in place and the re-run re-digests them — reconciliation NOOPs the already-written cards. This consumption is the only bookkeeping; there is no `.digest-state.md`.
59
-
60
- 7. **If qmd present, refresh the lens** (skip silently if `command -v qmd` finds nothing):
61
- - `qmd collection add docs/memory/facts --name {repo}-facts` — register the collection. Re-adding an existing name exits non-zero with "already exists"; **treat that as success.** Never register `docs/memory` as a whole — that would sweep the raw inbox queue into the index.
62
- - `qmd context add qmd://{repo}-facts/ "Distilled recent decisions/observations, provenance-linked"` — upserts, safe to re-run.
63
- - `qmd update` — **re-scan the collection for the cards you just wrote.** In qmd 2.5.3 `collection add` on an already-registered name does NOT re-index new files; `qmd update` is what picks them up. Skipping this leaves the new cards invisible to retrieval (to the `prompt-recall` hook, and to distill②).
64
- - `qmd embed -c {repo}-facts` — regenerate vectors for the new/changed cards. Always scope with `-c`; a bare `qmd embed` re-embeds every repo on the machine. The `prompt-recall` hook's vector fallback only sees embedded cards, so skipping this hides new cards from prompt-time recall.
65
-
66
- 8. **Report** (in chat). Cards added / updated / superseded / NOOP'd, the entries consumed per actor, and — if reconciliation fell back to filename/grep (qmd absent, **or** `{repo}-facts` registered but holding no cards yet — the first-run-on-a-scaffolded-facts-dir case) — the one advisory line saying so. Fire this advisory whenever semantic retrieval was effectively unavailable, even if the collection appeared in `qmd collection list`: a registered-but-empty collection is not semantic retrieval. A run that finds nothing surprising is **valid**: say "no surprising entries in the inbox," don't pad.
67
-
68
- ---
69
-
70
- ## Mode: `docs` (fact-cards → proposed doc deltas)
71
-
72
- Distill fact-cards into proposed edits to `docs/` (the cold, human-owned SSoT) — the next hop down the gradient from `facts`. The full procedure (input selection, delta drafting, digestion marking, the pillar escape valve, trigger wiring) is in [`references/docs-mode.md`](references/docs-mode.md) — follow it; reuses `method.md`'s surprisal / EDU / reconciliation / bi-temporal vocabulary unchanged. It NEVER writes a doc on its own — it **drafts and presents**; the human disposes.
73
-
74
- ---
75
-
76
- ## Rules
77
-
78
- - **One algorithm root.** Every mode reads `references/method.md`. Extend the spec there — never inline a divergent copy of the four methods in a mode.
79
- - **Consume, never rewrite.** `docs/memory/inbox/` is a queue owned by the `session-reflect` capture hook. The distiller deletes exactly the entry blocks it digested this run — no edits, no reordering, no touching undigested blocks. Beyond that consumption it writes only under `docs/memory/facts/`.
80
- - **Idempotent via consumption.** What is in the inbox is what is undigested. Consume last; a second run finds an empty inbox and is a NOOP.
81
- - **Cards are atomic and provenance-linked.** One self-contained statement per card; every card resolves back to the session ids and commits it came from. Decompose — never lossily summarize.
82
- - **Supersede ≠ delete.** Bi-temporal only: set `invalid_from:` + `superseded_by:` on the old card and leave the file in place. Nothing is ever removed from `docs/memory/facts/`.
83
- - **Findings are questions, not verdicts.** When a candidate appears to contradict an existing card, phrase it as a question (is X now superseded by Y?) — never assert staleness or falsity.
84
- - **Never auto-runs.** Invoked by the user, by the Completion flow (kit `CLAUDE.md`), or by accepting the maintenance proposal. No cron, no daemon, no hook self-trigger — a workflow step the agent runs is not a self-trigger.
85
- - **qmd is strictly optional.** Guard every qmd call on `command -v qmd`; absent ⇒ silent skip. Reconciliation additionally needs the `{repo}-facts` collection to hold **cards** — not merely to be registered. "Unbuilt" here means **no cards** (`docs/memory/facts/*.md` empty), not "unregistered": the `maintenance` agent's baseline mode registers + embeds `{repo}-facts` over an empty scaffolded facts dir, so on a qmd-equipped, freshly-baselined repo the collection _appears in_ `qmd collection list` yet indexes zero cards. Binary present but collection cardless (first run) falls back to filename/grep too, same as qmd being absent.
86
-
87
- ## Output locations
88
-
89
- - `facts` reads `docs/memory/inbox/`, writes fact-cards to `docs/memory/facts/{kebab-slug}.md`, and deletes the consumed entry blocks from the inbox; the report goes to chat.
90
- - `docs` presents doc deltas in chat and, **only on approval**, writes the accepted prose into the target doc under `docs/` (+ a new `docs/history/changelog/` entry if drafted) and appends `digested_into:` to each consumed card in `docs/memory/facts/`. See [`references/docs-mode.md`](references/docs-mode.md).