claude-mem-lite 3.79.0 → 3.80.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.79.0",
13
+ "version": "3.80.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.79.0",
3
+ "version": "3.80.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
@@ -20,9 +20,65 @@ import { readTranscriptEntries } from './transcript-scan.mjs';
20
20
  import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
21
21
 
22
22
  import { DAY_MS } from './time-constants.mjs';
23
+ /**
24
+ * The ONE caliber for an observation id appearing in text. Bounded to 1-7 digits to
25
+ * skip URL fragments, markdown anchors, etc.
26
+ *
27
+ * Exported because the offline benchmarks re-derive production's numbers from the same
28
+ * transcripts, and each had hand-copied its own: `benchmark/cite-recall.mjs` scanned
29
+ * citations with `{2,6}` while its OWN injected denominator used `{1,7}`, and
30
+ * `efficacy-observational.mjs` / `adoption-replay.mjs` had a third and fourth caliber
31
+ * (`{2,6}` / `{2,7}`). A denominator wider than its numerator counts an id as
32
+ * injected-never-cited that the numerator structurally cannot see, which biases the
33
+ * measured cite-rate DOWN — and nothing errors when it happens.
34
+ *
35
+ * Measured live impact at the time this was unified (2026-08-24, 3692 rows, ids 1..10834):
36
+ * exactly ZERO. The only ids outside `{2,6}` are four 1-digit rows, and all four have
37
+ * injection_count = 0, so they never entered a denominator; there are no 7-digit ids.
38
+ * This is a latent-class fix, not a correction to any published number — do not
39
+ * re-attribute past readings to it.
40
+ */
41
+ export const OBS_ID_DIGITS = '\\d{1,7}';
42
+
43
+ /**
44
+ * A fresh global matcher for a bare `#NN` citation.
45
+ *
46
+ * Returned fresh per call rather than shared: a `/g` regex carries `lastIndex`, so one
47
+ * exported instance reused by two scanners silently starts mid-string in whichever one
48
+ * runs second.
49
+ */
50
+ export function citationIdRe() {
51
+ return new RegExp(`#(${OBS_ID_DIGITS})\\b`, 'g');
52
+ }
53
+
54
+ /**
55
+ * Caliber for an id scraped from UNANCHORED text that is then treated as an INJECTED
56
+ * (denominator) set.
57
+ *
58
+ * `citationIdRe()` above is a NUMERATOR caliber. On the numerator side a spurious `#1`
59
+ * costs nothing, because a cited id only counts once it intersects an injected set that
60
+ * WAS anchored — every injected-side extractor in this module matches a row shape
61
+ * (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`). On the
62
+ * denominator side nothing anchors it, so a prose `#1` is a false positive by
63
+ * construction — it inflates "injected, never cited" and biases the measured rate DOWN.
64
+ *
65
+ * The v3.80.0 pre-tag review caught this concretely: pointing
66
+ * `benchmark/adoption-replay.mjs` at `citationIdRe()` pulled `#1` and `#2` out of a
67
+ * subagent prompt discussing fixture rows ("with `#1` superseded by `#2` …") straight into
68
+ * `injectedIds`, on a real transcript. Excluding 1-digit ids costs nothing measurable —
69
+ * the four 1-digit rows in the live corpus have `injection_count = 0` — and removes the
70
+ * commonest prose collision.
71
+ *
72
+ * This is a stopgap for a caliber symptom, NOT a fix for the cause. The cause is that
73
+ * adoption-replay scrapes a whole prompt where it should match injected ROWS, the way
74
+ * production does. Do not reach for this anywhere else; anchor instead.
75
+ */
76
+ export function unanchoredInjectedIdRe() {
77
+ return new RegExp('#(\\d{2,7})\\b', 'g');
78
+ }
79
+
23
80
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
24
- // Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
25
- const CITATION_RE = /#(\d{1,7})\b/g;
81
+ const CITATION_RE = citationIdRe();
26
82
 
27
83
  /**
28
84
  * Parse a Claude Code transcript .jsonl and extract unique observation IDs
@@ -147,7 +203,7 @@ export function bumpCitationAccess(db, ids, project) {
147
203
  // Matches a pre-tool-recall / error-recall lesson line: ` #NN [type] body...`.
148
204
  // Bounded type list mirrors observations.type CHECK + the events table's allowed
149
205
  // event_type values these surfaces can emit.
150
- const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
206
+ const INJECTED_RE = new RegExp(`#(${OBS_ID_DIGITS})\\s+\\[(bugfix|decision|change|discovery|feature|refactor|lesson)\\]`, 'g');
151
207
  // Line-anchored variant: a genuine injected ROW begins (after its short indent) with
152
208
  // `#NN [type]`. pre-tool-recall AND error-recall inline a lesson_learned body into the
153
209
  // row; a body that quotes another obs ("same as #1234 [decision]") must NOT count as
@@ -217,7 +273,7 @@ function eachHookAttachment(transcriptPath, fn, opts = {}) {
217
273
  // like "see (#999)" doesn't pollute the injected set (would streak-uncite an
218
274
  // obs we never actually displayed as a top-level entry).
219
275
  const UPS_LINE_PREFIX = '- [';
220
- const UPS_ID_RE = /\(#(\d{1,7})\)/g;
276
+ const UPS_ID_RE = new RegExp(`\\(#(${OBS_ID_DIGITS})\\)`, 'g');
221
277
  // Quote-normalized (see normalizeHookCommand): real recorded command is
222
278
  // `node "/abs/hook.mjs" user-prompt` → normalized to `node /abs/hook.mjs user-prompt`.
223
279
  const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
@@ -230,7 +286,7 @@ const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
230
286
  const FYI_HEADER = '[mem] FYI — Related memories';
231
287
  // Anchored at line start so `P#NN` past-question rows (user_prompts, different id
232
288
  // space) and any `#NN` inside lesson text are NOT matched.
233
- const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
289
+ const FYI_LINE_ID_RE = new RegExp(`^#(${OBS_ID_DIGITS})\\s`);
234
290
 
235
291
  /**
236
292
  * The injection FACES memory can reach the model through, as stored in
@@ -569,7 +625,7 @@ export function unionSurfaces(bySurface) {
569
625
  const SUBAGENT_INJECT_MARKER = /surfaced by your operator's claude-mem-lite/;
570
626
  // Row-anchored to the `#NN — ` tag so a #NN quoted inside the lesson body does NOT enter
571
627
  // the injected set — same discipline as INJECTED_ROW_RE for the attachment surfaces.
572
- const SUBAGENT_INJECT_ID_RE = /^\s{0,4}#(\d{1,7})\s+—/;
628
+ const SUBAGENT_INJECT_ID_RE = new RegExp(`^\\s{0,4}#(${OBS_ID_DIGITS})\\s+—`);
573
629
 
574
630
  /**
575
631
  * Extract observation ids injected into a subagent's PROMPT by pre-agent-inject.js
@@ -15,6 +15,9 @@ import { basename, join } from 'path';
15
15
  import { readFileSync } from 'fs';
16
16
  import { readTranscriptEntries } from './transcript-scan.mjs';
17
17
  import { EDIT_TOOLS } from '../utils.mjs';
18
+ // One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
19
+ // is acyclic.
20
+ import { citationIdRe } from './citation-tracker.mjs';
18
21
 
19
22
  const MAX_FILES = 2;
20
23
 
@@ -269,7 +272,9 @@ export function loadCiteBackForEpisode(episode, runtimeDir) {
269
272
  // #NN. The Stop handler unions these into the cited set passed to
270
273
  // applyCitationDecay (lib/citation-tracker.mjs), so acting on a lesson promotes
271
274
  // it and lifts the project's adoption rate. Returns an empty set on missing path.
272
- const CITE_BACK_ID_RE = /#(\d{1,7})\b/g;
275
+ // The ids collected here are unioned into the SAME cited set applyCitationDecay reads,
276
+ // so this caliber must be the extractor's own — imported, not a sixth hand-copy.
277
+ const CITE_BACK_ID_RE = citationIdRe();
273
278
 
274
279
  export function extractCiteBackSignals(transcriptPath) {
275
280
  const ids = new Set();
@@ -27,6 +27,40 @@
27
27
  let parts = [];
28
28
  let queuedEvent = null;
29
29
  let systemParts = [];
30
+ let queuedInput = null;
31
+
32
+ /** Emit the noisy drop notice. stderr is safe: the host never parses it as the envelope. */
33
+ function warnDrop(deps, msg) {
34
+ const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
35
+ warn(msg);
36
+ }
37
+
38
+ /**
39
+ * Claim this process's single hookEventName, or refuse the contribution.
40
+ *
41
+ * Mixed event names cannot be merged — Claude Code throws when
42
+ * hookSpecificOutput.hookEventName does not match the event it dispatched.
43
+ * In practice one process serves one event; keep the first and drop the
44
+ * stragglers rather than emit an envelope the host rejects outright.
45
+ *
46
+ * The drop is NOISY on purpose. It is unreachable today (all call sites are
47
+ * event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
48
+ * so a future caller that omits the argument would both mis-tag its receipt and
49
+ * have it swallowed without a trace. Silently vanishing work is this repo's
50
+ * most-repeated defect class.
51
+ *
52
+ * @returns {boolean} true when the caller may proceed.
53
+ */
54
+ function claimEvent(hookEventName, what, deps) {
55
+ if (queuedEvent && queuedEvent !== hookEventName) {
56
+ warnDrop(deps, `[claude-mem-lite] hook-stdout: dropped a ${hookEventName} ${what} — this process `
57
+ + `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
58
+ + 'This is a wiring bug: the contribution is lost.\n');
59
+ return false;
60
+ }
61
+ queuedEvent = hookEventName;
62
+ return true;
63
+ }
30
64
 
31
65
  /**
32
66
  * Queue a contribution to this process's single stdout envelope.
@@ -40,26 +74,40 @@ export function queueHookContext(hookEventName, text, deps = {}) {
40
74
  if (!hookEventName) return;
41
75
  const body = String(text ?? '').trim();
42
76
  if (!body) return;
43
- // Mixed event names cannot be merged — Claude Code throws when
44
- // hookSpecificOutput.hookEventName does not match the event it dispatched.
45
- // In practice one process serves one event; keep the first and drop the
46
- // stragglers rather than emit an envelope the host rejects outright.
47
- //
48
- // The drop is NOISY on purpose. It is unreachable today (all call sites are
49
- // event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
50
- // so a future caller that omits the argument would both mis-tag its receipt and
51
- // have it swallowed without a trace. Silently vanishing work is this repo's
52
- // most-repeated defect class; stderr is safe here because the host never parses it
53
- // as the envelope.
54
- if (queuedEvent && queuedEvent !== hookEventName) {
55
- const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
56
- warn(`[claude-mem-lite] hook-stdout: dropped a ${hookEventName} contribution this process `
57
- + `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
58
- + 'This is a wiring bug: the contribution is lost.\n');
77
+ if (!claimEvent(hookEventName, 'contribution', deps)) return;
78
+ parts.push(body);
79
+ }
80
+
81
+ /**
82
+ * Queue a `hookSpecificOutput.updatedInput` a REPLACEMENT of the tool's input,
83
+ * not a contribution to it. PreToolUse is the only event whose schema carries one
84
+ * (2.1.241 bundle: `{hookEventName: "PreToolUse", permissionDecision?,
85
+ * permissionDecisionReason?, updatedInput?, additionalContext?}`), and that same
86
+ * schema is why this belongs here rather than in its own writer: a mutation and a
87
+ * context line may ride ONE envelope, so a hook that grew both would otherwise
88
+ * emit two documents and lose both (the v3.70.0 degradation this module exists for).
89
+ *
90
+ * FIRST writer wins, and a second is dropped noisily. Unlike additionalContext
91
+ * there is no merge: two callers each hand over a whole tool_input, so last-wins
92
+ * would silently discard the earlier mutation — the same vanishing-work shape
93
+ * claimEvent guards against.
94
+ *
95
+ * @param {string} hookEventName Event name for hookSpecificOutput.
96
+ * @param {object} input Replacement tool_input; non-objects and null are ignored.
97
+ * @param {{warn?: (msg: string) => void}} [deps]
98
+ * @returns {void}
99
+ */
100
+ export function queueHookUpdatedInput(hookEventName, input, deps = {}) {
101
+ if (!hookEventName) return;
102
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return;
103
+ if (!claimEvent(hookEventName, 'updatedInput', deps)) return;
104
+ if (queuedInput) {
105
+ warnDrop(deps, '[claude-mem-lite] hook-stdout: dropped a second updatedInput — one envelope '
106
+ + 'replaces the tool input exactly once, and merging two whole inputs is not defined. '
107
+ + 'This is a wiring bug: the second mutation is lost.\n');
59
108
  return;
60
109
  }
61
- queuedEvent = hookEventName;
62
- parts.push(body);
110
+ queuedInput = input;
63
111
  }
64
112
 
65
113
  /**
@@ -93,24 +141,25 @@ export function queueHookSystemMessage(text) {
93
141
  * @returns {boolean} true when an envelope was written.
94
142
  */
95
143
  export function flushHookStdout(deps = {}) {
96
- const hasContext = queuedEvent && parts.length > 0;
144
+ const hasContext = Boolean(queuedEvent) && parts.length > 0;
145
+ const hasInput = Boolean(queuedEvent) && queuedInput !== null;
97
146
  const hasSystem = systemParts.length > 0;
98
- if (!hasContext && !hasSystem) return false;
147
+ if (!hasContext && !hasInput && !hasSystem) return false;
99
148
  const write = deps.write || ((s) => process.stdout.write(s));
100
149
  const envelope = { suppressOutput: true };
101
150
  if (hasSystem) envelope.systemMessage = systemParts.join('\n');
102
- // Omitted entirely when there is no model-facing context: Stop's schema REJECTS a
103
- // hookSpecificOutput block, and an envelope carrying only a user notice must not
104
- // invent an event name to hang one on.
105
- if (hasContext) {
106
- envelope.hookSpecificOutput = {
107
- hookEventName: queuedEvent,
108
- additionalContext: parts.join('\n\n'),
109
- };
151
+ // Omitted entirely when there is nothing addressed to the host's per-event block:
152
+ // Stop's schema REJECTS a hookSpecificOutput block, and an envelope carrying only a
153
+ // user notice must not invent an event name to hang one on.
154
+ if (hasContext || hasInput) {
155
+ envelope.hookSpecificOutput = { hookEventName: queuedEvent };
156
+ if (hasInput) envelope.hookSpecificOutput.updatedInput = queuedInput;
157
+ if (hasContext) envelope.hookSpecificOutput.additionalContext = parts.join('\n\n');
110
158
  }
111
159
  parts = [];
112
160
  queuedEvent = null;
113
161
  systemParts = [];
162
+ queuedInput = null;
114
163
  write(JSON.stringify(envelope) + '\n');
115
164
  return true;
116
165
  }
@@ -120,9 +169,15 @@ export function resetHookStdout() {
120
169
  parts = [];
121
170
  queuedEvent = null;
122
171
  systemParts = [];
172
+ queuedInput = null;
123
173
  }
124
174
 
125
175
  /** Test seam: what is queued right now. */
126
176
  export function peekHookStdout() {
127
- return { hookEventName: queuedEvent, parts: [...parts], systemParts: [...systemParts] };
177
+ return {
178
+ hookEventName: queuedEvent,
179
+ parts: [...parts],
180
+ systemParts: [...systemParts],
181
+ updatedInput: queuedInput,
182
+ };
128
183
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.79.0",
3
+ "version": "3.80.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.79.0",
9
+ "version": "3.80.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.79.0",
3
+ "version": "3.80.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -17,6 +17,11 @@ import { existsSync, readFileSync } from 'fs';
17
17
  import { basename, join } from 'path';
18
18
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
19
19
  import { recordHookError } from '../lib/hook-telemetry.mjs';
20
+ // D#154: every envelope on this stdout goes through the one writer. This script has a
21
+ // single emit today, so the change buys nothing on its own — it buys that a SECOND
22
+ // emit added later merges instead of producing two JSON documents, which the host
23
+ // parses as neither (lib/hook-stdout.mjs). Import-free module over no runtime deps.
24
+ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
20
25
 
21
26
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
22
27
 
@@ -69,10 +74,8 @@ async function main() {
69
74
  for (const d of dropped.slice(0, 3)) {
70
75
  lines.push(`[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`);
71
76
  }
72
- process.stdout.write(JSON.stringify({
73
- suppressOutput: true,
74
- hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') },
75
- }));
77
+ queueHookContext('PostToolUse', lines.join('\n'));
78
+ flushHookStdout();
76
79
  }
77
80
 
78
81
  // No forced process.exit(0): main() consumes stdin to EOF (or early-returns without
@@ -66,15 +66,28 @@ async function main() {
66
66
  const { ensureDb } = await import('../schema.mjs');
67
67
  const { inferProject } = await import('../utils.mjs');
68
68
  const { buildSubagentInjection } = await import('../hook-memory.mjs');
69
+ // D#154: single envelope writer. Deferred to this line, not hoisted to a static
70
+ // import, because the file's stated contract is that the default-off path costs one
71
+ // env check and nothing else — the deferral filed this as "shared module vs
72
+ // import-free fast path, pick one", but the script already resolves that conflict
73
+ // three lines up: dynamic import on the enabled path only. The fast path above is
74
+ // untouched.
75
+ const { queueHookUpdatedInput, flushHookStdout } = await import('../lib/hook-stdout.mjs');
69
76
 
70
77
  let db;
71
78
  try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
72
79
  try {
73
80
  const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
74
81
  if (updatedInput) {
75
- process.stdout.write(JSON.stringify({
76
- hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput },
77
- }));
82
+ // Behaviour delta vs the hand-written envelope this replaced: it now carries
83
+ // top-level `suppressOutput: true`. Verified display-only in the 2.1.241 bundle —
84
+ // the field is documented "Hide stdout from transcript (default: false)" and is
85
+ // read at exactly one place, the transcript-render branch
86
+ // (`if (a6(he) && !he.suppressOutput && …)`); the updatedInput mutation is taken
87
+ // from the parsed hookSpecificOutput regardless. Hiding it is also the right
88
+ // audience call: this payload is the whole prompt echoed back, not a message.
89
+ queueHookUpdatedInput('PreToolUse', updatedInput);
90
+ flushHookStdout();
78
91
  }
79
92
  } catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
80
93
  try { db.close(); } catch { /* */ }
@@ -11,6 +11,9 @@ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
11
11
  // format-utils.mjs is import-free — pulling three defang helpers keeps this script
12
12
  // inside its "lightweight standalone" budget (no heavy transitive deps).
13
13
  import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
14
+ // D#154: single envelope writer. Also import-free (no runtime deps), so it stays
15
+ // inside this script's "lightweight standalone" budget.
16
+ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
14
17
 
15
18
  // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
16
19
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
@@ -110,13 +113,8 @@ try {
110
113
  } else {
111
114
  additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
112
115
  }
113
- process.stdout.write(JSON.stringify({
114
- suppressOutput: true,
115
- hookSpecificOutput: {
116
- hookEventName: 'PreToolUse',
117
- additionalContext,
118
- },
119
- }));
116
+ queueHookContext('PreToolUse', additionalContext);
117
+ flushHookStdout();
120
118
  } catch (e) {
121
119
  // Silent failure — never block Skill tool, but record for self-observation.
122
120
  recordHookError('skill-bridge:query', e, RUNTIME_DIR, { skillName });
@@ -19,6 +19,25 @@ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/rerea
19
19
  import { recordMetric } from '../lib/metrics.mjs';
20
20
  import { presentIdents } from '../lib/lesson-idents.mjs';
21
21
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
22
+ // D#154: the one stdout writer. This script has THREE emit sites (Read→Edit ack,
23
+ // repeated-read guard, lesson block) and they stay one document because each branch
24
+ // process.exit()s before reaching the next.
25
+ //
26
+ // Be precise about what routing them through the queue does and does not buy, because an
27
+ // earlier version of this comment claimed "a second write is now impossible by
28
+ // construction" and that is FALSE (pre-tag review, v3.80.0): each site flushes
29
+ // IMMEDIATELY after queueing, and the flush resets the queue — so queue→flush→queue→flush
30
+ // emits two documents exactly like two raw writes would. Merging is a property of
31
+ // DEFERRING the flush (what hook.mjs does with a single flush at the end of its dispatch),
32
+ // not of using the queue.
33
+ //
34
+ // What it does buy: one construction site instead of three, so the "only the writer
35
+ // assembles an envelope" invariant is checkable (tests/hook-script-stdout-contract.test.mjs),
36
+ // and the merge is AVAILABLE to anyone who later defers the flush. The mutual exclusion
37
+ // itself is still control flow — the process.exit(0) below.
38
+ //
39
+ // Import-free module, no runtime deps — nothing added to this script's load cost.
40
+ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
22
41
  // Recall queries the SAVE-path project, so this MUST produce the same string as the
23
42
  // save path. It used to be a hand-kept copy of the same 6 lines; that copy had already
24
43
  // drifted once (missing the process.env.PWD fallback, so a symlinked project dir
@@ -305,16 +324,11 @@ try {
305
324
  const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
306
325
  if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
307
326
  const idList = seenIds.map(id => `#${id}`).join(', ');
308
- process.stdout.write(JSON.stringify({
309
- suppressOutput: true,
310
- hookSpecificOutput: {
311
- hookEventName: 'PreToolUse',
312
- additionalContext: [
313
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
314
- `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
315
- ].join('\n'),
316
- },
317
- }));
327
+ queueHookContext('PreToolUse', [
328
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
329
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
330
+ ].join('\n'));
331
+ flushHookStdout();
318
332
  cooldown[filePath] = { ...entry, mode: 'edit' };
319
333
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
320
334
  } else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
@@ -322,16 +336,11 @@ try {
322
336
  // nudge to reuse what's already in context. Read-only; never throws.
323
337
  const meta = readFileMeta(filePath);
324
338
  if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
325
- process.stdout.write(JSON.stringify({
326
- suppressOutput: true,
327
- hookSpecificOutput: {
328
- hookEventName: 'PreToolUse',
329
- additionalContext: [
330
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
331
- buildRereadWarning(basename(filePath), entry.reread.tokens),
332
- ].join('\n'),
333
- },
334
- }));
339
+ queueHookContext('PreToolUse', [
340
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
341
+ buildRereadWarning(basename(filePath), entry.reread.tokens),
342
+ ].join('\n'));
343
+ flushHookStdout();
335
344
  recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
336
345
  }
337
346
  }
@@ -605,13 +614,8 @@ try {
605
614
  }
606
615
 
607
616
  if (lines.length > 0) {
608
- process.stdout.write(JSON.stringify({
609
- suppressOutput: true,
610
- hookSpecificOutput: {
611
- hookEventName: 'PreToolUse',
612
- additionalContext: lines.join('\n'),
613
- },
614
- }));
617
+ queueHookContext('PreToolUse', lines.join('\n'));
618
+ flushHookStdout();
615
619
  }
616
620
  // Cooldown applies on ALL branches (including silent-Read) so subsequent
617
621
  // calls on the same file in the same session don't re-query — preserving