dsh-engram 0.7.2 → 0.8.6

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 (54) hide show
  1. package/README.md +104 -7
  2. package/README.zh.md +13 -3
  3. package/docs/CONTEXT-GC-GUIDE.zh.md +15 -1
  4. package/docs/G6-quality-ab.zh.md +540 -0
  5. package/docs/GATES.zh.md +41 -0
  6. package/docs/PROPOSAL-evolution.zh.md +12 -0
  7. package/docs/RELEASE-NOTES-v0.7.10.md +49 -0
  8. package/docs/RELEASE-NOTES-v0.7.11.md +31 -0
  9. package/docs/RELEASE-NOTES-v0.7.12.md +22 -0
  10. package/docs/RELEASE-NOTES-v0.7.13.md +41 -0
  11. package/docs/RELEASE-NOTES-v0.7.14.md +37 -0
  12. package/docs/RELEASE-NOTES-v0.7.15.md +36 -0
  13. package/docs/RELEASE-NOTES-v0.7.16.md +42 -0
  14. package/docs/RELEASE-NOTES-v0.7.3.md +30 -0
  15. package/docs/RELEASE-NOTES-v0.7.4.md +71 -0
  16. package/docs/RELEASE-NOTES-v0.7.5.md +134 -0
  17. package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
  18. package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
  19. package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
  20. package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
  21. package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
  22. package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
  23. package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
  24. package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
  25. package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
  26. package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
  27. package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
  28. package/eval/gate-audit.mjs +538 -0
  29. package/lib/api.js +70 -0
  30. package/lib/approvals.js +84 -0
  31. package/lib/boundary-prune.js +71 -5
  32. package/lib/capsule.js +134 -0
  33. package/lib/capture.js +85 -6
  34. package/lib/client.js +1813 -1745
  35. package/lib/compaction.js +4 -11
  36. package/lib/context-gc.js +63 -2
  37. package/lib/episode.js +10 -5
  38. package/lib/index-block.js +89 -8
  39. package/lib/index.js +108 -21
  40. package/lib/ledger.js +200 -10
  41. package/lib/pack-telemetry.js +10 -3
  42. package/lib/preference.js +2 -1
  43. package/lib/prune.js +89 -0
  44. package/lib/session-events.js +168 -0
  45. package/lib/skill-export.js +289 -0
  46. package/lib/status-insights.js +5 -0
  47. package/lib/status.js +138 -0
  48. package/lib/store.js +34 -0
  49. package/lib/todo-sink.js +3 -2
  50. package/lib/tools.js +2 -1
  51. package/lib/usage.js +32 -3
  52. package/lib/web-provision.js +59 -14
  53. package/package.json +15 -8
  54. package/scripts/dsh-engram.mjs +229 -4
@@ -0,0 +1,84 @@
1
+ /**
2
+ * v0.8-D: the approval gate for artifacts that leave the memory store.
3
+ *
4
+ * Why this exists: v0.8-A made a procedure promotable by evidence, and v0.8-B
5
+ * turns promoted procedures into files in `.dsh/skills` that the host
6
+ * advertises to every agent. Evidence proves a command *worked*; it does not
7
+ * prove the instruction is *sound* — three successful runs of a harmful recipe
8
+ * are still three successful runs. So the two surfaces are gated differently on
9
+ * purpose:
10
+ *
11
+ * - the [ENGRAM] index line is cheap, reversible and private → evidence gate
12
+ * (automatic, v0.8-A);
13
+ * - a skill file is durable, discoverable and shared → evidence gate **plus**
14
+ * an explicit human yes (this module).
15
+ *
16
+ * The record lives next to the ledger, not inside the memory store: an approval
17
+ * is a human act, and mixing it into the pool would let the same pool "approve"
18
+ * itself. Shape:
19
+ *
20
+ * { "version": 1, "workspaces": { "<ws>": { "<memoryId>": { at, note } } } }
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ export const APPROVALS_VERSION = 1;
26
+
27
+ export function approvalsPath(home) {
28
+ return path.join(home, "playbook-approvals.json");
29
+ }
30
+
31
+ /** Read the approval file; a missing or corrupt file means "nothing approved". */
32
+ export function loadApprovals(home) {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(approvalsPath(home), "utf8"));
35
+ if (parsed === null || typeof parsed !== "object") return empty();
36
+ return {
37
+ version: APPROVALS_VERSION,
38
+ workspaces: parsed.workspaces !== null && typeof parsed.workspaces === "object" ? parsed.workspaces : {},
39
+ };
40
+ } catch {
41
+ return empty();
42
+ }
43
+ }
44
+
45
+ function empty() {
46
+ return { version: APPROVALS_VERSION, workspaces: {} };
47
+ }
48
+
49
+ /** Approved ids for one workspace (empty when the workspace has none). */
50
+ export function approvedIds(state, workspace) {
51
+ const bucket = state?.workspaces?.[workspace];
52
+ return bucket !== null && typeof bucket === "object" ? Object.keys(bucket) : [];
53
+ }
54
+
55
+ export function isApproved(state, workspace, memoryId) {
56
+ return state?.workspaces?.[workspace]?.[memoryId] !== undefined;
57
+ }
58
+
59
+ function save(home, state) {
60
+ const file = approvalsPath(home);
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ const tmp = `${file}.tmp`;
63
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
64
+ fs.renameSync(tmp, file);
65
+ return state;
66
+ }
67
+
68
+ export function recordApproval(home, workspace, memoryId, { note = null, at = Date.now() } = {}) {
69
+ if (typeof memoryId !== "string" || memoryId.length === 0) throw new Error("approve needs a memory id");
70
+ const state = loadApprovals(home);
71
+ state.workspaces[workspace] = state.workspaces[workspace] ?? {};
72
+ state.workspaces[workspace][memoryId] = { at, note };
73
+ return save(home, state);
74
+ }
75
+
76
+ export function revokeApproval(home, workspace, memoryId) {
77
+ const state = loadApprovals(home);
78
+ const bucket = state.workspaces[workspace];
79
+ if (bucket !== undefined) {
80
+ delete bucket[memoryId];
81
+ if (Object.keys(bucket).length === 0) delete state.workspaces[workspace];
82
+ }
83
+ return save(home, state);
84
+ }
@@ -86,12 +86,34 @@ export function isBoundary(event, state) {
86
86
  }
87
87
 
88
88
  /** Sessions per workspace with ≥1 request, as (count, medianRequests) map — L0 repayment input. */
89
+ /**
90
+ * One key for "which workspace is this", from either side of the lookup.
91
+ *
92
+ * Session buckets are named after the cwd with `/` flattened to `-`
93
+ * (`--Users-me-dsh-loom--`), while the live session header carries the absolute
94
+ * path (`/Users/me/dsh-loom`). Comparing those two strings never matches, and an
95
+ * unmatched workspace falls back to the bare floor — which makes the repayment
96
+ * gate (`requests + minRemaining > expectedTotal`) true for every session past
97
+ * its first request, i.e. L1 armed and permanently silent. Normalising both
98
+ * sides is what makes the history lookup real.
99
+ * @param {string | undefined | null} workspace
100
+ * @returns {string}
101
+ */
102
+ export function normalizeWorkspaceKey(workspace) {
103
+ if (typeof workspace !== "string") return "";
104
+ const flat = workspace
105
+ .trim()
106
+ .replace(/[-\/\\]+/g, "-")
107
+ .replace(/^-+|-+$/g, "");
108
+ return process.platform === "win32" ? flat.toLowerCase() : flat;
109
+ }
110
+
89
111
  export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "sessions")) {
90
112
  const byWorkspace = new Map();
91
113
  try {
92
114
  for (const bucket of fs.readdirSync(root)) {
93
115
  if (!bucket.startsWith("--")) continue;
94
- const ws = decodeURIComponent(bucket.replace(/^--/, "").replace(/--$/, ""));
116
+ const ws = normalizeWorkspaceKey(decodeURIComponent(bucket.replace(/^--+/, "").replace(/--+$/, "")));
95
117
  let sessions = 0;
96
118
  const bdir = path.join(root, bucket);
97
119
  for (const sid of fs.readdirSync(bdir)) {
@@ -120,7 +142,7 @@ export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "session
120
142
  * must not starve them).
121
143
  */
122
144
  export function expectedSessionRequests(requestsSoFar, history, workspace, floor = DEFAULT_MIN_REMAINING) {
123
- const entry = history?.get(workspace);
145
+ const entry = history?.get(workspace) ?? history?.get(normalizeWorkspaceKey(workspace));
124
146
  const prior = entry && entry.sessions > 0 ? Math.max(floor, EMPIRICAL_MEDIAN_REQUESTS) : floor;
125
147
  if (requestsSoFar <= prior) return prior;
126
148
  // past the prior: project the session to run at least as long again as it
@@ -189,6 +211,15 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
189
211
  // this is purely evidence-based off, never speculative on.
190
212
  const adaptive = config?.boundaryPruneAdaptive !== false;
191
213
  const allowFire = typeof opts?.allowFire === "function" ? opts.allowFire : () => true;
214
+ // Diagnostics hook: every boundary the listener actually SEES is reported
215
+ // (skipped by the repayment gate, held by the ledger gate, or fired) so that
216
+ // "armed but never fires" can be told apart from "no boundary ever came".
217
+ // A throwing hook must never disturb the session pipeline.
218
+ const report = (kind, fields = {}) => {
219
+ try {
220
+ opts?.onAttempt?.({ kind, ...fields });
221
+ } catch { /* diagnostics only */ }
222
+ };
192
223
  const blockedNotified = new Set();
193
224
 
194
225
  // Optional threshold override (ResultPack L0, T2048 landing): the host pruner
@@ -258,7 +289,7 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
258
289
  const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
259
290
  if (state.requests + minRemaining <= expectedTotal) {
260
291
  // reuse the same fire path via a synthetic boundary
261
- return fireBoundary(session, { kind: "step" }, state, expectedTotal);
292
+ return fireDeferred(session, { kind: "step" }, state, expectedTotal);
262
293
  }
263
294
  }
264
295
  }
@@ -273,14 +304,40 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
273
304
  // requests beyond the current count.
274
305
  const workspace = wk(session?.header?.cwd);
275
306
  const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
276
- if (state.requests + minRemaining > expectedTotal) return;
277
- return fireBoundary(session, boundary, state, expectedTotal);
307
+ if (state.requests + minRemaining > expectedTotal) {
308
+ report("skipped", { boundary, requests: state.requests, expectedTotal, workspace });
309
+ return;
310
+ }
311
+ return fireDeferred(session, boundary, state, expectedTotal);
278
312
  } catch (error) {
279
313
  // fully contained — a prune bug must never disturb the session pipeline
314
+ report("error", { where: "listener", message: String(error) });
280
315
  log?.warn?.(`engram boundary-prune threw: ${String(error)}`);
281
316
  }
282
317
  };
283
318
 
319
+ /**
320
+ * Fire OUTSIDE the `commit()` call stack.
321
+ *
322
+ * `session/event` observers run while the session is publishing an event
323
+ * (`commit()` sets `appending` for the duration). Pruning appends its own
324
+ * `compaction/prune` event, and DSH refuses a reentrant append ("session
325
+ * append cannot reenter while another append is being published"). Firing
326
+ * synchronously therefore works only while there is NOTHING to prune — the
327
+ * exact shape of "armed, reports fired, prunes zero, forever". The microtask
328
+ * runs after commit() returns, where append is legal again.
329
+ */
330
+ const fireDeferred = (session, boundary, state, expectedTotal) => {
331
+ queueMicrotask(() => {
332
+ try {
333
+ fireBoundary(session, boundary, state, expectedTotal);
334
+ } catch (error) {
335
+ report("error", { where: "fire", boundary, message: String(error) });
336
+ log?.warn?.(`engram boundary-prune: fire threw: ${String(error)}`);
337
+ }
338
+ });
339
+ };
340
+
284
341
  /** Shared fire path for every boundary source (event + step-cadence). */
285
342
  const fireBoundary = (session, boundary, state, expectedTotal) => {
286
343
  if (adaptive) {
@@ -292,6 +349,7 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
292
349
  allowed = true; // a gate bug must never disable pruning
293
350
  }
294
351
  if (!allowed) {
352
+ report("held", { boundary, requests: state.requests, workspace });
295
353
  if (!blockedNotified.has(workspace)) {
296
354
  blockedNotified.add(workspace);
297
355
  log?.info?.(
@@ -301,8 +359,16 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
301
359
  return;
302
360
  }
303
361
  }
362
+ const prunerWorkspace = wk(session?.header?.cwd);
304
363
  const result = activePruner.pruneSession(session);
305
364
  const prunedNow = result?.pruned?.length ?? 0;
365
+ report("fired", {
366
+ boundary,
367
+ requests: state.requests,
368
+ pruned: prunedNow,
369
+ charsRemoved: Number.isFinite(result?.charsRemoved) ? result.charsRemoved : 0,
370
+ workspace: prunerWorkspace,
371
+ });
306
372
  if (prunedNow > 0) {
307
373
  state.prunes += 1;
308
374
  state.prunedResults += prunedNow;
package/lib/capsule.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * v0.8-C (advisory): subagent handoff capsules.
3
+ *
4
+ * The proposal wants the parent agent to receive a ≤500-token capsule instead of
5
+ * a child's full report ("conclusion / evidence / open questions / next step").
6
+ * That substitution needs a seam the host does not expose today: `tools/result`
7
+ * is emitted through `ctx.events.dispatch("emit", …)` and the emitter's own
8
+ * comment says it notifies "observers without exposing a mutation or error
9
+ * channel into the outcome" — a listener cannot rewrite what the parent reads.
10
+ *
11
+ * So this module ships the half that IS reachable, and is honest about the
12
+ * difference:
13
+ *
14
+ * - it distills a child's report into a deterministic capsule and stores it as
15
+ * a `handoff` memory, so the conclusion survives the child session and can be
16
+ * recalled after a compaction (the full text stays re-fetchable in the child's
17
+ * session log);
18
+ * - it records the source/capsule sizes in the memory itself, so the
19
+ * "how much would substitution have saved" question is measurable instead of
20
+ * asserted.
21
+ *
22
+ * Zero LLM: the capsule is extracted by structure (headings, bullets, question
23
+ * marks), never summarized by a model.
24
+ */
25
+
26
+ export const CAPSULE_TAG = "subagent-capsule";
27
+
28
+ /** Tool names whose result is a subagent's final report. */
29
+ export function isSubagentTool(name) {
30
+ return typeof name === "string" && (name === "subagent" || name === "subagent_fork" || name === "agent");
31
+ }
32
+
33
+ const HEADING_RE = /^\s{0,3}#{1,6}\s+(.*)$/;
34
+ const BULLET_RE = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/;
35
+ const EVIDENCE_RE = /(test|pass|fail|bench|artifact|eval|commit|✔|✓|✗|→|path|\/[\w.-]+\.(?:js|mjs|ts|md|json|yml|yaml|py|go|rs)\b|#\d|sha|https?:)/i;
36
+ const OPEN_RE = /(\?|todo|open question|unresolved|blocked|未决|待定|风险|risk)/i;
37
+ const NEXT_RE = /(next step|next:|下一步|建议|recommend|should (?:then|now)|follow-?up|remaining)/i;
38
+
39
+ function cleanLine(line) {
40
+ return String(line ?? "")
41
+ .replace(/^[-*+]\s+/, "")
42
+ .replace(/\s+/g, " ")
43
+ .trim();
44
+ }
45
+
46
+ /**
47
+ * Deterministic capsule of a long report.
48
+ *
49
+ * Sections are best-effort structural extraction; the footer always states what
50
+ * was compressed and where the original lives, so a reader can tell a capsule
51
+ * from a report.
52
+ */
53
+ export function buildCapsule(text, { maxChars = 1800, source = "subagent report" } = {}) {
54
+ const full = String(text ?? "").trim();
55
+ if (full.length === 0) return { capsule: "", sourceChars: 0, capsuleChars: 0 };
56
+ // Keep raw lines: bullet/heading detection needs their prefixes, rendering does not.
57
+ const raw = full.split("\n").filter((l) => l.trim().length > 0);
58
+ const isHeading = (l) => /^\s{0,3}#{1,6}\s+/.test(l);
59
+ const bulletOf = (l) => /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l)?.[1] ?? null;
60
+
61
+ // Conclusion: the opening prose before the first heading/bullet, bounded so a
62
+ // wall of text cannot become the "conclusion".
63
+ const conclusionLines = [];
64
+ for (const line of raw) {
65
+ if (isHeading(line) || bulletOf(line) !== null) {
66
+ if (conclusionLines.length > 0) break;
67
+ continue;
68
+ }
69
+ conclusionLines.push(cleanLine(line));
70
+ if (conclusionLines.join(" ").length > 300) break;
71
+ }
72
+ const conclusion = conclusionLines.join(" ").slice(0, 300);
73
+
74
+ const bullets = raw.map(bulletOf).filter((b) => b !== null).map(cleanLine);
75
+ const evidence = bullets.filter((b) => EVIDENCE_RE.test(b)).slice(0, 5);
76
+ const open = bullets.filter((b) => OPEN_RE.test(b)).slice(0, 3);
77
+ const next = bullets.filter((b) => NEXT_RE.test(b) && !open.includes(b)).slice(0, 3);
78
+
79
+ const parts = [];
80
+ const structured = evidence.length > 0 || open.length > 0 || next.length > 0;
81
+ if (structured) {
82
+ if (conclusion.length > 0) parts.push(`**结论**:${conclusion}`);
83
+ if (evidence.length > 0) parts.push(`**证据**:\n${evidence.map((e) => `- ${e}`).join("\n")}`);
84
+ if (open.length > 0) parts.push(`**未决**:\n${open.map((e) => `- ${e}`).join("\n")}`);
85
+ if (next.length > 0) parts.push(`**下一步**:\n${next.map((e) => `- ${e}`).join("\n")}`);
86
+ } else {
87
+ // Unstructured prose: keep both ends and mark the elision instead of
88
+ // pretending a summary happened.
89
+ const head = raw.slice(0, 2).map(cleanLine).join("\n");
90
+ const tail = raw.slice(-2).map(cleanLine).join("\n");
91
+ parts.push(`**无结构可提炼(保留首尾)**:\n${head}\n…\n${tail}`);
92
+ }
93
+
94
+ const footer = (chars) =>
95
+ `\n\n---\n胶囊:${source} ${full.length} → ${chars} 字符;全文在子代理会话日志里(可用 session 搜索回取)。`;
96
+ const room = Math.max(200, maxChars - 120);
97
+ let body = parts.join("\n\n");
98
+ if (body.length > room) body = `${body.slice(0, room).trimEnd()}…`;
99
+
100
+ let capsule = `${body}${footer(body.length)}`;
101
+ // A "compression" that is not smaller is a bug, not a summary: fall back to a
102
+ // straight head/tail cut that is guaranteed to fit.
103
+ if (capsule.length >= full.length) {
104
+ const cut = Math.max(120, Math.floor((full.length - 200) * 0.7));
105
+ const head = full.slice(0, cut).trimEnd();
106
+ const tail = full.slice(-Math.min(80, Math.max(0, full.length - cut - 1))).trim();
107
+ body = `**无结构可提炼(保留首尾)**:\n${head}…\n…${tail}`;
108
+ capsule = `${body}${footer(body.length)}`;
109
+ if (capsule.length >= full.length) capsule = full.slice(0, Math.max(80, full.length - 1));
110
+ }
111
+ return { capsule, sourceChars: full.length, capsuleChars: capsule.length };
112
+ }
113
+
114
+ /** Read the text out of a tool result (string, content blocks, or value fields). */
115
+ export function resultText(result) {
116
+ const value = result?.value;
117
+ if (typeof value === "string" && value.length > 0) return value;
118
+ if (value !== null && typeof value === "object") {
119
+ if (Array.isArray(value.content)) {
120
+ const joined = value.content
121
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
122
+ .map((b) => b.text)
123
+ .join("\n");
124
+ if (joined.length > 0) return joined;
125
+ }
126
+ if (typeof value.text === "string" && value.text.length > 0) return value.text;
127
+ }
128
+ const content = result?.content;
129
+ if (Array.isArray(content)) {
130
+ const joined = content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
131
+ if (joined.length > 0) return joined;
132
+ }
133
+ return null;
134
+ }
package/lib/capture.js CHANGED
@@ -21,6 +21,8 @@
21
21
  */
22
22
 
23
23
  import { workspaceKey as workspaceKeyOf } from "./util.js";
24
+ import { sessionSeq } from "./session-events.js";
25
+ import { buildCapsule, isSubagentTool, resultText } from "./capsule.js";
24
26
 
25
27
  const GIT_MILESTONE_SIGNAL = 0.5;
26
28
  const GIT_COMMIT_SIGNAL = 0.55;
@@ -50,7 +52,7 @@ export function cmdTag(command) {
50
52
  * error rows. Deterministic; fires only on genuine same-command
51
53
  * success-after-failure.
52
54
  */
53
- export function fixClosureEntry(name, command, result, previousErrors) {
55
+ export function fixClosureEntry(name, command, result, previousErrors, at = Date.now()) {
54
56
  if (!["bash", "shell", "exec"].includes(name)) return null;
55
57
  if (result?.isError === true) return null; // not a success
56
58
  if (typeof command !== "string" || command.trim().length === 0) return null;
@@ -70,11 +72,38 @@ export function fixClosureEntry(name, command, result, previousErrors) {
70
72
  signal: FIX_CLOSURE_SIGNAL,
71
73
  tags: ["procedure", "auto-captured", "fix", tag, ...(isTest ? ["test"] : [])],
72
74
  filePath: null,
75
+ // One success is never enough to generalize (v0.8-A): the row starts with
76
+ // a single evidence point and has to earn an index line.
77
+ evidenceCount: 1,
78
+ lastVerifiedAt: at,
73
79
  },
74
80
  resolvedIds: failing.map((m) => m.id),
75
81
  };
76
82
  }
77
83
 
84
+ /**
85
+ * v0.8-A: what should happen to an existing `fixed:` procedure when the SAME
86
+ * command runs again? Returns `{ procedureId, ok }` or null.
87
+ *
88
+ * Success → credit one evidence point; failure → invalidate immediately.
89
+ * Deterministic, zero LLM, and scoped to the workspace's active rows.
90
+ */
91
+ export function procedureOutcome(name, command, result, rows) {
92
+ if (!isCommandRun(name)) return null;
93
+ if (typeof command !== "string" || command.trim().length === 0) return null;
94
+ const tag = cmdTag(command);
95
+ if (tag === null) return null;
96
+ const procedure = (rows ?? []).find(
97
+ (m) =>
98
+ m?.kind === "procedure" &&
99
+ m.status === "active" &&
100
+ /^fixed:/.test(String(m.text ?? "")) &&
101
+ (m.tags ?? []).includes(tag),
102
+ );
103
+ if (procedure === void 0) return null;
104
+ return { procedureId: procedure.id, ok: result?.isError !== true };
105
+ }
106
+
78
107
  /** Git operations that carry lasting meaning (milestones). */
79
108
  const GIT_OPS = [
80
109
  // commit: only with an explicit -m subject — that's the semantic content.
@@ -153,9 +182,42 @@ export function makeCaptureHandler(store, config, log, stats) {
153
182
 
154
183
  const args = (exec.arguments ?? {});
155
184
  const git = extractGit(name, args.command);
185
+ // Evidence accounting applies to EVERY command run, not only the runs that
186
+ // close a failure: a procedure only keeps its index line while it keeps
187
+ // holding (v0.8-A).
188
+ const recentRows = store.listMemories !== void 0 ? store.listMemories(workspaceKeyOf(cwd), 200) : [];
189
+ const outcome = procedureOutcome(name, args.command, result, recentRows);
190
+ const workspace = workspaceKeyOf(cwd);
191
+ const applyOutcome = (errorId = null) => {
192
+ if (outcome === null) return;
193
+ void store
194
+ .recordProcedureOutcome?.(workspace, outcome.procedureId, { ok: outcome.ok, errorId })
195
+ ?.catch?.(() => null);
196
+ };
156
197
  let entry = null;
157
198
 
158
- if (git !== null) {
199
+ // v0.8-C (advisory): a subagent's report is durable knowledge worth
200
+ // keeping even though the host will not let us shrink what the parent
201
+ // reads (see lib/capsule.js). Long reports become a capsule memory.
202
+ const subagentReport = isSubagentTool(name) && result?.isError !== true ? resultText(result) : null;
203
+ const capsuleMin = config.capsuleMinSourceChars ?? 1500;
204
+ if (subagentReport !== null && subagentReport.length >= capsuleMin) {
205
+ const { capsule } = buildCapsule(subagentReport, {
206
+ maxChars: config.capsuleMaxChars ?? 1800,
207
+ source: String(args.description ?? name),
208
+ });
209
+ entry = {
210
+ kind: "handoff",
211
+ text: capsule,
212
+ signal: 0.55,
213
+ tags: ["handoff", "auto-captured", "subagent-capsule", `agent:${String(args.description ?? name).slice(0, 60)}`],
214
+ filePath: null,
215
+ };
216
+ }
217
+
218
+ if (entry !== null) {
219
+ // already drafted above
220
+ } else if (git !== null) {
159
221
  entry = { kind: "decision", text: git.text, signal: git.signal, tags: git.tags };
160
222
  } else if (result?.isError === true) {
161
223
  // Errors take precedence over tool-name classification: a failed edit
@@ -198,7 +260,13 @@ export function makeCaptureHandler(store, config, log, stats) {
198
260
  }
199
261
  }
200
262
 
201
- if (entry === null) return;
263
+ if (entry === null) {
264
+ // No new memory to store, but a command run still carries evidence for
265
+ // an existing procedure (v0.8-A): credit the success, or invalidate on
266
+ // failure. This is the common case — most runs produce no memory at all.
267
+ applyOutcome();
268
+ return;
269
+ }
202
270
  if (stats) {
203
271
  stats.total += 1;
204
272
  if (entry.kind === "decision") stats.git += 1;
@@ -208,7 +276,14 @@ export function makeCaptureHandler(store, config, log, stats) {
208
276
 
209
277
  // exact-duplicate guard across the recent window (store re-dedups by hash anyway)
210
278
  const key = `${entry.kind}|${entry.text}`;
211
- if (recent.has(key)) return;
279
+ if (recent.has(key)) {
280
+ // Exact repeat within the window: nothing new to store, but the run is
281
+ // still evidence for or against the procedure (v0.8-A). A repeated
282
+ // failure invalidates by the row it repeats, not by a phantom id.
283
+ const existing = recentRows.find((m) => m.kind === entry.kind && (m.text ?? "") === entry.text);
284
+ applyOutcome(existing?.id ?? null);
285
+ return;
286
+ }
212
287
  recent.add(key);
213
288
  if (recent.size > 500) {
214
289
  const first = recent.values().next().value;
@@ -225,17 +300,21 @@ export function makeCaptureHandler(store, config, log, stats) {
225
300
  tags: entry.tags,
226
301
  signal: entry.signal,
227
302
  filePath: entry.filePath ?? null,
303
+ // v0.8-A: a closure row carries its first evidence point at birth.
304
+ evidenceCount: entry.evidenceCount ?? 0,
305
+ lastVerifiedAt: entry.lastVerifiedAt ?? null,
228
306
  sessionId,
229
- seq: agent.session.events?.length ?? 0,
307
+ seq: sessionSeq(agent.session),
230
308
  },
231
309
  config,
232
310
  )
233
- .then(() => {
311
+ .then((saved) => {
234
312
  // Failure→fix closure: mark the earlier error rows resolved (tag
235
313
  // append only — never re-ranks them, per store.tagMemory's contract).
236
314
  for (const id of resolvedIds) {
237
315
  void store.tagMemory?.(workspaceKeyOf(cwd), id, "resolved")?.catch?.(() => null);
238
316
  }
317
+ applyOutcome(saved?.id ?? null);
239
318
  })
240
319
  .catch((error) => log?.warn?.(`engram auto-capture failed: ${String(error)}`));
241
320
  } catch (error) {