claude-mem-lite 3.61.1 → 3.63.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -0
- package/cli/common.mjs +78 -5
- package/format-utils.mjs +86 -1
- package/hook-handoff.mjs +11 -2
- package/hook-llm.mjs +3 -3
- package/hook-shared.mjs +14 -1
- package/hook.mjs +134 -18
- package/hooks/hooks.json +10 -0
- package/install.mjs +64 -2
- package/lib/db-backup.mjs +84 -1
- package/lib/export-columns.mjs +7 -1
- package/lib/recall-core.mjs +8 -0
- package/mem-cli.mjs +50 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-recall.js +12 -2
- package/scripts/pre-agent-inject.js +22 -4
- package/scripts/pre-skill-bridge.js +16 -3
- package/scripts/pre-tool-recall.js +26 -10
- package/scripts/prompt-search-utils.mjs +6 -2
- package/scripts/user-prompt-search.js +38 -11
- package/search-engine.mjs +36 -6
- package/search-scoring.mjs +7 -2
- package/server.mjs +102 -41
- package/source-files.mjs +30 -1
- package/tool-schemas.mjs +10 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.63.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.
|
|
3
|
+
"version": "3.63.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"
|
package/README.md
CHANGED
|
@@ -529,6 +529,32 @@ Notes:
|
|
|
529
529
|
- Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
|
|
530
530
|
- If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
|
|
531
531
|
|
|
532
|
+
#### Trust model per install path
|
|
533
|
+
|
|
534
|
+
The three install paths do **not** carry the same supply-chain guarantees — pick the one that matches your threat model:
|
|
535
|
+
|
|
536
|
+
| Path | Update mechanism | Ed25519 release-signature verification |
|
|
537
|
+
|------|------------------|----------------------------------------|
|
|
538
|
+
| npm / npx / git-clone direct install | auto-update from GitHub Releases | **Yes** — every runtime file (140 entries incl. hook scripts, MCP launcher, plugin declaration files) is hash-pinned in a signed manifest; verification is fail-closed |
|
|
539
|
+
| `/plugin install` (marketplace) | manual `/plugin marketplace update` + reinstall | **No** — Claude Code installs from a git clone of the marketplace repo; the plugin's own signature chain is not consulted on this path. You are trusting GitHub + the repo's branch protection, not the release signing key |
|
|
540
|
+
|
|
541
|
+
**Rollback recipe (plugin path).** If an update misbehaves, pin the marketplace clone to the previous release tag and reinstall from it:
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
# 1. Find the local marketplace clone
|
|
545
|
+
ls ~/.claude/plugins/marketplaces/ # e.g. sdsrss
|
|
546
|
+
|
|
547
|
+
# 2. Pin it to the previous good tag (tags mirror npm versions, e.g. v3.62.0)
|
|
548
|
+
cd ~/.claude/plugins/marketplaces/sdsrss
|
|
549
|
+
git fetch --tags && git checkout v3.62.0
|
|
550
|
+
|
|
551
|
+
# 3. Reinstall from the pinned clone — inside Claude Code:
|
|
552
|
+
# /plugin install claude-mem-lite@sdsrss
|
|
553
|
+
# 4. To leave the pin later: git checkout main, then the normal update flow.
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node install.mjs doctor` before trusting search results.
|
|
557
|
+
|
|
532
558
|
### doctor
|
|
533
559
|
|
|
534
560
|
Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
|
package/cli/common.mjs
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// Extracted from mem-cli.mjs (v2.41) as first step in the god-module split.
|
|
3
3
|
//
|
|
4
4
|
// Scope: pure utilities only. No DB, no imports from other cli/ files; only
|
|
5
|
-
// `lib/`
|
|
6
|
-
// parseIdToken). This module is the single source
|
|
7
|
-
// framing, arg parsing, ID-token parsing, and
|
|
8
|
-
// every command imports from here so the CLI stays
|
|
5
|
+
// leaf utilities from `lib/` and the repo root may be pulled in (currently:
|
|
6
|
+
// parseIdToken, neutralizeContextDelimiters). This module is the single source
|
|
7
|
+
// of truth for stdout/stderr framing, arg parsing, ID-token parsing, and
|
|
8
|
+
// relative-time formatting — every command imports from here so the CLI stays
|
|
9
|
+
// consistent.
|
|
10
|
+
|
|
11
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
9
12
|
|
|
10
13
|
// ─── Argument Parsing ────────────────────────────────────────────────────────
|
|
11
14
|
|
|
@@ -85,8 +88,41 @@ export function parseArgs(argv) {
|
|
|
85
88
|
|
|
86
89
|
// ─── Output Helpers ──────────────────────────────────────────────────────────
|
|
87
90
|
|
|
88
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Write a line to stdout, with structural context delimiters neutralized.
|
|
93
|
+
*
|
|
94
|
+
* CLI stdout IS model context, not just a human channel: commands/mem.md routes
|
|
95
|
+
* `/mem search|get|recall|timeline` to `node cli.mjs … via Bash`, and
|
|
96
|
+
* buildServerInstructions actively tells the agent the Bash CLI is the CHEAPER path
|
|
97
|
+
* than the MCP tool. The MCP read family has been defanged since v3.61 at its own
|
|
98
|
+
* chokepoint (server.mjs safeHandler), but the CLI twins printed stored text raw —
|
|
99
|
+
* so the exact indirect-prompt-injection channel the MCP defang closes stayed open on
|
|
100
|
+
* the surface the instructions recommend (audit 2026-08-14 A1). Observations are
|
|
101
|
+
* stored raw on purpose (defense lives at the injection boundary, not at save), so it
|
|
102
|
+
* has to happen here, at the write.
|
|
103
|
+
*
|
|
104
|
+
* `out` is the single stdout writer for every command in mem-cli.mjs and cli/*.mjs,
|
|
105
|
+
* so a NEW read command is covered by construction (§9 parallel-path completeness).
|
|
106
|
+
* Payloads that must round-trip byte-exact use `outVerbatim` instead — see below.
|
|
107
|
+
* The transform is idempotent (it strips brackets, it does not re-add them), so a
|
|
108
|
+
* path that already defanged upstream — `context` → buildSessionContextLines — is
|
|
109
|
+
* unaffected.
|
|
110
|
+
*/
|
|
89
111
|
export function out(text) {
|
|
112
|
+
// String() first: neutralizeContextDelimiters coerces nullish to '', which would turn
|
|
113
|
+
// a pre-existing `out(undefined)` line from "undefined" into an empty line.
|
|
114
|
+
outVerbatim(neutralizeContextDelimiters(String(text)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write a line to stdout with NO defang. The CLI mirror of
|
|
119
|
+
* `safeHandler(fn, { verbatim: true })` on the MCP side, and for the same single
|
|
120
|
+
* reason: `export` is the backup half of backup/restore, so neutralizing its payload
|
|
121
|
+
* would silently rewrite every backed-up row whose text legitimately contains these
|
|
122
|
+
* tags — and `restore` would write the rewritten text back. Only use this for bytes
|
|
123
|
+
* that must survive a round trip; anything a model reads goes through `out`.
|
|
124
|
+
*/
|
|
125
|
+
export function outVerbatim(text) {
|
|
90
126
|
process.stdout.write(text + '\n');
|
|
91
127
|
}
|
|
92
128
|
|
|
@@ -260,6 +296,43 @@ export function fmtDateShort(iso) {
|
|
|
260
296
|
// because the formatter lived only in mem-cli.mjs.
|
|
261
297
|
export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
|
|
262
298
|
|
|
299
|
+
// Display labels for observation columns whose NAME misdescribes their contents.
|
|
300
|
+
// `files_modified` holds whatever file list the writer attached: hook-captured rows fill
|
|
301
|
+
// it from Edit/Write, but an explicit mem_save / `save --files` puts any associated path
|
|
302
|
+
// there — including a file that was only read. Rendering the raw column name told the
|
|
303
|
+
// reader those files were modified (audit 2026-08-14 F3). The label is `files` — the name
|
|
304
|
+
// of the input parameter that fills it. The COLUMN is untouched, so `--fields
|
|
305
|
+
// files_modified` / `fields:["files_modified"]` still select it. Shared by the CLI `get`
|
|
306
|
+
// and MCP `mem_get` renderers so the two cannot drift.
|
|
307
|
+
const OBS_FIELD_LABELS = { files_modified: 'files' };
|
|
308
|
+
|
|
309
|
+
/** Reader-facing label for an observation column (identity for everything unmapped). */
|
|
310
|
+
export function obsFieldLabel(field) {
|
|
311
|
+
return OBS_FIELD_LABELS[field] || field;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The `maintain scan` pending-purge line, shared by the CLI (mem-cli.mjs cmdMaintain) and
|
|
316
|
+
* the MCP mem_maintain handler (server.mjs) so the two cannot drift — same reason
|
|
317
|
+
* obsFieldLabel lives here.
|
|
318
|
+
*
|
|
319
|
+
* The count is `compressed_into = COMPRESSED_PENDING_PURGE` (lib/maintain-core.mjs:443),
|
|
320
|
+
* and the ONLY writers of that sentinel are the two idle/decay passes — decayAndMarkIdle
|
|
321
|
+
* (maintain-core.mjs:201) and runIdleCleanup (search-scoring.mjs:303). Compression writes
|
|
322
|
+
* COMPRESSED_AUTO (-1) or a positive parent id, and those rows are NOT counted. The CLI
|
|
323
|
+
* used to render this as "compressed originals awaiting cleanup", which told an operator
|
|
324
|
+
* about to run `maintain execute --ops purge_stale --confirm` that they were deleting
|
|
325
|
+
* compression leftovers when they were deleting decay-marked live originals (audit
|
|
326
|
+
* 2026-08-14 A4). Say what the rows are and what deletes them — and say nothing about
|
|
327
|
+
* compression, which is a different sentinel with a different lifecycle.
|
|
328
|
+
*
|
|
329
|
+
* @param {number} n stats.pendingPurge
|
|
330
|
+
* @returns {string} the full indented line, identical on both surfaces.
|
|
331
|
+
*/
|
|
332
|
+
export function formatPendingPurgeLine(n) {
|
|
333
|
+
return ` Pending purge (idle-marked): ${n} (live originals marked idle by decay — purge_stale deletes them)`;
|
|
334
|
+
}
|
|
335
|
+
|
|
263
336
|
// Pure formatter — null/undefined/non-time pass through; integer time fields
|
|
264
337
|
// render as `<raw> (<relative>)` so callers get both an audit value and a
|
|
265
338
|
// human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
|
package/format-utils.mjs
CHANGED
|
@@ -48,15 +48,100 @@ export function truncate(str, max = 80) {
|
|
|
48
48
|
// itself, where source/observations carry the delimiter names.
|
|
49
49
|
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
|
|
50
50
|
|
|
51
|
+
// Pass cap for the fixpoint loop below. 32 nested layers of a forged delimiter is far past
|
|
52
|
+
// anything prose produces; the cap exists only to bound the ADVERSARIAL cost (an unbounded
|
|
53
|
+
// fixpoint is O(depth \u00d7 length), i.e. quadratic on a crafted 200k-char payload, on the
|
|
54
|
+
// synchronous hook/CLI write path \u2014 this repo has shipped two ReDoS findings already).
|
|
55
|
+
const DEFANG_MAX_PASSES = 32;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strip the angle brackets of every `re` match, repeatedly, until the text stops changing.
|
|
59
|
+
*
|
|
60
|
+
* A SINGLE pass is not enough, and the gap is two characters wide: in
|
|
61
|
+
* `<<system-reminder>>` the pattern matches the INNER pair, removing it leaves the outer
|
|
62
|
+
* pair wrapped around the bare tag name, and the "defanged" output is a live
|
|
63
|
+
* `<system-reminder>` \u2014 produced by the function whose job is to make it inert (pre-tag
|
|
64
|
+
* review, 2026-08-14). Widening the pattern to also eat adjacent brackets does not fix it
|
|
65
|
+
* either: `<system-reminder <system-reminder> x>y>` re-forms `<system-reminder x>` from
|
|
66
|
+
* text the widened match keeps. Only a fixpoint closes the general case.
|
|
67
|
+
*
|
|
68
|
+
* TERMINATION: every match begins with `<` and the replacement drops it, so any pass that
|
|
69
|
+
* changes the string removes at least one `<` \u2014 the iteration is self-bounded by the number
|
|
70
|
+
* of `<` in the input, and DEFANG_MAX_PASSES bounds it again by a constant.
|
|
71
|
+
*
|
|
72
|
+
* INERT AT ANY DEPTH: if the text is still changing when the cap is reached (\u226532 nested
|
|
73
|
+
* forged layers \u2014 not reachable by accident), every remaining angle bracket is removed.
|
|
74
|
+
* That is lossier than the normal path, but the return value then provably contains no tag,
|
|
75
|
+
* which is the property callers rely on; giving up at the cap and returning still-tagged
|
|
76
|
+
* text would hand the attacker exactly the bypass the loop exists to close.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} s Input string (any type; coerced)
|
|
79
|
+
* @param {RegExp} re Global tag pattern whose matches start with `<` and end with `>`
|
|
80
|
+
* @returns {string} Text containing no match of `re`
|
|
81
|
+
*/
|
|
82
|
+
function defangToFixpoint(s, re) {
|
|
83
|
+
let text = String(s ?? '');
|
|
84
|
+
for (let pass = 0; pass < DEFANG_MAX_PASSES; pass++) {
|
|
85
|
+
const next = text.replace(re, (m) => m.slice(1, -1));
|
|
86
|
+
if (next === text) return text; // fixpoint: nothing left to defang
|
|
87
|
+
text = next;
|
|
88
|
+
}
|
|
89
|
+
return text.replace(/[<>]/g, ''); // pathological nesting \u2192 fail closed
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
/**
|
|
52
93
|
* Defang the literal context-block delimiter tags in user-derived text. Strips just the
|
|
53
94
|
* angle brackets, so `</claude-mem-context>` renders as `/claude-mem-context` \u2014 still
|
|
54
95
|
* readable, but no longer a structural delimiter. Complements `mdCell`'s pipe-escaping.
|
|
96
|
+
* Iterated to a fixpoint (see defangToFixpoint): a single pass let `<<system-reminder>>`
|
|
97
|
+
* come back live.
|
|
55
98
|
* @param {string} s Input string (any type; coerced)
|
|
56
99
|
* @returns {string} Text with delimiter tags defanged
|
|
57
100
|
*/
|
|
58
101
|
export function neutralizeContextDelimiters(s) {
|
|
59
|
-
return
|
|
102
|
+
return defangToFixpoint(s, CONTEXT_DELIMITER_RE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// <skill-loaded> is deliberately NOT in CONTEXT_DELIMITER_RE above: mem_use's legitimate
|
|
106
|
+
// load path has to emit a REAL one, and that result goes through the same handler-wide
|
|
107
|
+
// defang, which would strip it. So the tag is neutralized here instead — per call site,
|
|
108
|
+
// on the untrusted text only. Attribute-bearing openers and the bare closer both match,
|
|
109
|
+
// same "strip the brackets, keep the text" treatment as the class above.
|
|
110
|
+
const SKILL_BLOCK_RE = /<\/?skill-loaded(?:\s[^>]*)?>/gi;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Defang a literal `<skill-loaded>` opener/closer in text that is about to be echoed
|
|
114
|
+
* INSIDE a mem_use response. A caller-supplied name interpolated raw could otherwise
|
|
115
|
+
* forge a whole skill block (plus its execute imperative) in a message the caller
|
|
116
|
+
* controls end to end — audit F7, 2026-08-14. Never apply this to the real load path.
|
|
117
|
+
* Same fixpoint iteration as the class above, for the same reason: one pass turned the
|
|
118
|
+
* caller-supplied `<<skill-loaded>>` back into a live `<skill-loaded>` opener.
|
|
119
|
+
* @param {string} s Input string (any type; coerced)
|
|
120
|
+
* @returns {string} Text with skill-block delimiters defanged
|
|
121
|
+
*/
|
|
122
|
+
export function neutralizeSkillDelimiters(s) {
|
|
123
|
+
return defangToFixpoint(s, SKILL_BLOCK_RE);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
|
|
127
|
+
// skill body it injects as PreToolUse additionalContext. The body comes from a
|
|
128
|
+
// third-party repo (tools/adopt import) — an untrusted boundary — so a literal
|
|
129
|
+
// `</skill-bridge>` inside it would close the wrapper early and spill the rest of
|
|
130
|
+
// the payload (e.g. a forged <system-reminder>) as undelimited context (audit
|
|
131
|
+
// 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
|
|
132
|
+
// isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
|
|
133
|
+
// call site to the untrusted body only.
|
|
134
|
+
const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
|
|
138
|
+
* to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
|
|
139
|
+
* classes above. Never apply to the wrapper itself.
|
|
140
|
+
* @param {string} s Input string (any type; coerced)
|
|
141
|
+
* @returns {string} Text with skill-bridge delimiters defanged
|
|
142
|
+
*/
|
|
143
|
+
export function neutralizeSkillBridgeDelimiters(s) {
|
|
144
|
+
return defangToFixpoint(s, SKILL_BRIDGE_RE);
|
|
60
145
|
}
|
|
61
146
|
|
|
62
147
|
/**
|
package/hook-handoff.mjs
CHANGED
|
@@ -173,11 +173,20 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
173
173
|
try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// 5. Key decisions — high importance observations (skip low-signal degraded titles)
|
|
176
|
+
// 5. Key decisions — high importance observations (skip low-signal degraded titles).
|
|
177
|
+
//
|
|
178
|
+
// superseded_at IS NULL, unlike `completed` and `files_modified` above: those two are
|
|
179
|
+
// the session's own history ("what happened here"), where an overturned decision still
|
|
180
|
+
// happened and erasing it would misreport the session. key_decisions is different — it
|
|
181
|
+
// is replayed to the NEXT session under "## Key Decisions" as standing policy, so a
|
|
182
|
+
// retracted decision rendered there is indistinguishable from live policy. The
|
|
183
|
+
// carry-forward fallback at the top of this function already filters the same column;
|
|
184
|
+
// this is the sibling that did not.
|
|
177
185
|
const decisions = db.prepare(`
|
|
178
186
|
SELECT title FROM observations
|
|
179
187
|
WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
|
|
180
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
188
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
189
|
+
AND superseded_at IS NULL ${obsWindowClause}
|
|
181
190
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
182
191
|
`).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
|
|
183
192
|
|
package/hook-llm.mjs
CHANGED
|
@@ -156,7 +156,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
156
156
|
// (no lesson, importance<2, empty facts, thin narrative) is dropped before
|
|
157
157
|
// dedup/MinHash/vector work. Opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1.
|
|
158
158
|
if (isNoiseObservation(obs)) {
|
|
159
|
-
debugLog('saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
|
|
159
|
+
debugLog('DEBUG', 'saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
|
|
160
160
|
return null;
|
|
161
161
|
}
|
|
162
162
|
|
|
@@ -172,7 +172,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
172
172
|
// pre-save is not lost on LLM success — that path clean-inserts a fresh row — but it loses
|
|
173
173
|
// those three.) capNoiseImportance then caps any title-noise survivor to imp=1 as before.
|
|
174
174
|
if (isLowYieldChangeObs(obs)) {
|
|
175
|
-
debugLog('saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
|
|
175
|
+
debugLog('DEBUG', 'saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
|
|
176
176
|
return null;
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -182,7 +182,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
182
182
|
// enter the 7-day accelerated auto-compress window in hook.mjs.
|
|
183
183
|
const capped = capNoiseImportance(obs);
|
|
184
184
|
if (capped !== (obs.importance ?? 1)) {
|
|
185
|
-
debugLog('saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
|
|
185
|
+
debugLog('DEBUG', 'saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
|
|
186
186
|
obs.importance = capped;
|
|
187
187
|
}
|
|
188
188
|
|
package/hook-shared.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { join } from 'path';
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
9
|
import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
10
|
+
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
|
+
// neither imports this file) — no cycle.
|
|
12
|
+
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
10
13
|
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
|
|
11
14
|
// Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
|
|
12
15
|
// fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
|
|
@@ -157,7 +160,17 @@ export function openDb() {
|
|
|
157
160
|
// WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
|
|
158
161
|
// silently dead (null DB) on a corrupt WAL until the next MCP server start.
|
|
159
162
|
return ensureDbWithWalRecovery();
|
|
160
|
-
} catch {
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// Still null, still no throw — a hook must never crash the host session, and all
|
|
165
|
+
// eight call sites in hook.mjs are written to no-op on null. But "returned null"
|
|
166
|
+
// used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
|
|
167
|
+
// reported 0 and doctor printed "no recent silent hook breakage" while every
|
|
168
|
+
// capture path was dead (audit B1, 2026-08-14 — the same blindness that hid the
|
|
169
|
+
// v3.60 binding outage for four days). recordHookError is the established sink;
|
|
170
|
+
// scripts/pre-tool-recall.js already logs its own db-open failures this way, and
|
|
171
|
+
// routing through it also flags the native-binding family for the session-start
|
|
172
|
+
// self-heal. The recorder swallows its own errors, so this cannot throw.
|
|
173
|
+
recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
|
|
161
174
|
return null;
|
|
162
175
|
}
|
|
163
176
|
}
|
package/hook.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
48
48
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
49
49
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
50
|
+
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
50
51
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
51
52
|
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
52
53
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
@@ -94,8 +95,14 @@ const event = process.argv[2];
|
|
|
94
95
|
// the dispatch below exits everything else). EVERY spawnBackground/queue* event
|
|
95
96
|
// MUST be listed here — a missing entry makes the detached worker exit(0)
|
|
96
97
|
// silently, which looks identical to "worker ran and found nothing" from the
|
|
97
|
-
// outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line
|
|
98
|
-
|
|
98
|
+
// outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line;
|
|
99
|
+
// audit F6, 2026-08-14: update-check had been dead the same way, so the 24h
|
|
100
|
+
// release check never ran and every SessionStart respawned a worker that
|
|
101
|
+
// exit(0)'d before its handler).
|
|
102
|
+
// `tests/audit-findings-20260814.test.mjs` scans BOTH detached spawners
|
|
103
|
+
// (spawnBackground here, spawn(node,[HOOK_PATH,…]) in lib/save-enrich.mjs) and
|
|
104
|
+
// reds when a spawned event is missing from this list.
|
|
105
|
+
const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain', 'enrich-save', 'update-check']);
|
|
99
106
|
|
|
100
107
|
// Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
|
|
101
108
|
// install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
|
|
@@ -139,8 +146,19 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
139
146
|
// sessions into one garbled row. planEpisodeFlush returns [ep] by reference when
|
|
140
147
|
// there is ≤1 CC session (the common case → identical to before), else one sub
|
|
141
148
|
// per session. Pure/sync → safe inside the signal handler.
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
// Same B1 gate as flushEpisode: without an openable DB every save below is a
|
|
150
|
+
// no-op, so deleting the buffer afterwards would destroy the very episode this
|
|
151
|
+
// handler exists to salvage. openDb is sync (safe here) and records its own
|
|
152
|
+
// failure; leaving the file untouched lets the next fire retry it.
|
|
153
|
+
const db = openDb();
|
|
154
|
+
if (db) {
|
|
155
|
+
try {
|
|
156
|
+
for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub, db);
|
|
157
|
+
try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
|
|
158
|
+
} finally {
|
|
159
|
+
try { db.close(); } catch { /* already gone */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
144
162
|
}
|
|
145
163
|
} catch {}
|
|
146
164
|
process.exit(0);
|
|
@@ -164,6 +182,25 @@ const RECEIPT_EVENTS = new Set(['PostToolUse', 'SessionStart', 'UserPromptSubmit
|
|
|
164
182
|
function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
165
183
|
if (!episode || episode.entries.length === 0) return;
|
|
166
184
|
|
|
185
|
+
// Acquire the DB ONCE, up front, and bail before touching anything destructive when it
|
|
186
|
+
// will not open. Every persistence step below is a no-op without it (saveObservation
|
|
187
|
+
// returns null on a null db; the detached llm-episode worker hits the same wall), yet
|
|
188
|
+
// the `unlinkSync(episodeFile())` at the tail used to run regardless — so a DB that
|
|
189
|
+
// could not be opened deleted the session's captured work while the hook exited 0 with
|
|
190
|
+
// empty stdout AND empty stderr (audit B1, 2026-08-14). Returning here leaves the
|
|
191
|
+
// buffer on disk for the next fire to retry; openDb() has already recorded the failure
|
|
192
|
+
// under `hook-shared:db-open`. Reusing the handle for the immediate saves also drops
|
|
193
|
+
// this path from one open per sub-episode to one per flush.
|
|
194
|
+
const db = openDb();
|
|
195
|
+
if (!db) return;
|
|
196
|
+
try {
|
|
197
|
+
flushEpisodeWithDb(db, episode, hookEventName);
|
|
198
|
+
} finally {
|
|
199
|
+
try { db.close(); } catch { /* already closed / gone */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
167
204
|
// Collect Read file paths tracked by post-tool-use.sh
|
|
168
205
|
// Use rename to atomically collect — prevents losing concurrent appends
|
|
169
206
|
const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
|
|
@@ -185,7 +222,7 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
185
222
|
const subs = planEpisodeFlush(episode);
|
|
186
223
|
let anySignificant = false;
|
|
187
224
|
for (const sub of subs) {
|
|
188
|
-
const r = flushEpisodeGroup(sub);
|
|
225
|
+
const r = flushEpisodeGroup(sub, db);
|
|
189
226
|
if (r === 'writefail') {
|
|
190
227
|
// Single-group: preserve the original early return — buffer left un-unlinked
|
|
191
228
|
// for a later retry, no receipt. Multi-group: skip only the failed group and
|
|
@@ -250,7 +287,7 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
250
287
|
// 'significant' | 'insignificant' | 'writefail'. CLAUDE_MEM_SKIP_EPISODE_LLM
|
|
251
288
|
// suppresses the detached enrichment spawn (test determinism; sibling of
|
|
252
289
|
// CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
|
|
253
|
-
function flushEpisodeGroup(ep) {
|
|
290
|
+
function flushEpisodeGroup(ep, db) {
|
|
254
291
|
const isSignificant = episodeHasSignificantContent(ep);
|
|
255
292
|
|
|
256
293
|
// Immediate save: rule-based observation for instant visibility; the LLM
|
|
@@ -258,7 +295,9 @@ function flushEpisodeGroup(ep) {
|
|
|
258
295
|
if (isSignificant) {
|
|
259
296
|
try {
|
|
260
297
|
const obs = buildImmediateObservation(ep);
|
|
261
|
-
|
|
298
|
+
// `db` is flushEpisode's handle — passed in so the caller owns open/close and the
|
|
299
|
+
// whole flush is gated on one availability check (B1).
|
|
300
|
+
const id = saveObservation(obs, ep.project, ep.sessionId, db);
|
|
262
301
|
if (id) ep.savedId = id;
|
|
263
302
|
} catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
|
|
264
303
|
}
|
|
@@ -300,6 +339,23 @@ async function handlePostToolUse() {
|
|
|
300
339
|
|
|
301
340
|
const { tool_name, tool_input, tool_response } = hookData;
|
|
302
341
|
if (!tool_name) return;
|
|
342
|
+
// A non-string tool_name is a host-protocol violation, not a payload we can handle:
|
|
343
|
+
// `tool_name.startsWith(p)` two lines down threw a TypeError that the top-level catch
|
|
344
|
+
// absorbed, so the observation was dropped with nothing attributable behind it. Guard the
|
|
345
|
+
// type (parity with scripts/pre-skill-bridge.js:43) and RECORD it rather than dropping
|
|
346
|
+
// quietly: PostToolUse is the plugin's whole capture path, so a host field-shape change
|
|
347
|
+
// would kill every observation, and hook-errors/ is the only window into that — the same
|
|
348
|
+
// blindness that let the v3.60 binding outage run for 4 days. Volume is bounded by the
|
|
349
|
+
// recorder's 14-day retention and one short line per fire.
|
|
350
|
+
if (typeof tool_name !== 'string') {
|
|
351
|
+
recordHookError(
|
|
352
|
+
'post-tool-use:tool_name-type',
|
|
353
|
+
new TypeError(`tool_name is ${Array.isArray(tool_name) ? 'array' : typeof tool_name}, expected string`),
|
|
354
|
+
RUNTIME_DIR,
|
|
355
|
+
{ toolNameType: Array.isArray(tool_name) ? 'array' : typeof tool_name },
|
|
356
|
+
);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
303
359
|
|
|
304
360
|
// Skip noise (source of truth: skip-tools.mjs)
|
|
305
361
|
if (SKIP_TOOLS.has(tool_name)) return;
|
|
@@ -426,9 +482,24 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
426
482
|
FROM observations_fts
|
|
427
483
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
428
484
|
WHERE observations_fts MATCH ? AND o.project = ?
|
|
485
|
+
-- Live-row invariant, same as every other model-facing retrieval path
|
|
486
|
+
-- (hook-context obsPool/fallbackObs/keyObs, hook-memory, search-engine,
|
|
487
|
+
-- recent/search/timeline/recall-core, pre-tool-recall, user-prompt-search).
|
|
488
|
+
-- This surface INLINES rows[0].lesson_learned into the model context, so an
|
|
489
|
+
-- unfiltered SELECT handed a retracted lesson to the agent verbatim while its
|
|
490
|
+
-- correction trailed as a bare pointer. compressed_into is filtered too, not
|
|
491
|
+
-- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
|
|
492
|
+
-- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
|
|
493
|
+
-- so that pointer would resolve to nothing.
|
|
494
|
+
AND COALESCE(o.compressed_into, 0) = 0
|
|
495
|
+
AND o.superseded_at IS NULL
|
|
429
496
|
AND ${notLowSignalTitleClause('o')}
|
|
497
|
+
-- MAX(0, …) clamps recency age to >= 0 (parity with search-engine FULL_SCORE):
|
|
498
|
+
-- a far-future created_at (reachable via restore/import-jsonl, which accept
|
|
499
|
+
-- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
|
|
500
|
+
-- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
|
|
430
501
|
ORDER BY ${OBS_BM25}
|
|
431
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
|
|
502
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / 1209600000.0))
|
|
432
503
|
LIMIT 3
|
|
433
504
|
`).all(ftsQuery, project, nowR);
|
|
434
505
|
|
|
@@ -497,10 +568,17 @@ async function handleStop() {
|
|
|
497
568
|
// Prevents data loss from concurrent PostToolUse writes between read and delete.
|
|
498
569
|
const epFile = episodeFile();
|
|
499
570
|
const claimFile = epFile + `.claim-${process.pid}-${Date.now()}`;
|
|
571
|
+
// Third instance of the B1 gate (flushEpisode and the SIGTERM salvage are the other
|
|
572
|
+
// two): this path already MOVED the buffer out of the way, so with no openable DB the
|
|
573
|
+
// `unlinkSync(claimFile)` below would destroy it just as surely — and the 1h orphan
|
|
574
|
+
// sweep would have eaten a restored-but-unnoticed claim file anyway. Open once, and
|
|
575
|
+
// put the buffer back under its real name when the save cannot happen.
|
|
576
|
+
let claimDb;
|
|
500
577
|
try {
|
|
501
578
|
renameSync(epFile, claimFile);
|
|
579
|
+
claimDb = openDb();
|
|
502
580
|
try {
|
|
503
|
-
const episode = JSON.parse(readFileSync(claimFile, 'utf8'));
|
|
581
|
+
const episode = claimDb ? JSON.parse(readFileSync(claimFile, 'utf8')) : null;
|
|
504
582
|
if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
|
|
505
583
|
if (!episode.sessionId) episode.sessionId = sessionId;
|
|
506
584
|
if (!episode.project) episode.project = project;
|
|
@@ -519,7 +597,7 @@ async function handleStop() {
|
|
|
519
597
|
if (!episodeHasSignificantContent(sub)) continue;
|
|
520
598
|
try {
|
|
521
599
|
const obs = buildImmediateObservation(sub);
|
|
522
|
-
const id = saveObservation(obs, sub.project, sub.sessionId);
|
|
600
|
+
const id = saveObservation(obs, sub.project, sub.sessionId, claimDb);
|
|
523
601
|
if (id) sub.savedId = id;
|
|
524
602
|
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
525
603
|
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
@@ -528,7 +606,15 @@ async function handleStop() {
|
|
|
528
606
|
}
|
|
529
607
|
}
|
|
530
608
|
} finally {
|
|
531
|
-
|
|
609
|
+
if (claimDb) {
|
|
610
|
+
try { unlinkSync(claimFile); } catch {}
|
|
611
|
+
try { claimDb.close(); } catch { /* already gone */ }
|
|
612
|
+
} else {
|
|
613
|
+
// Nothing was (or could be) persisted — restore the buffer under its real name
|
|
614
|
+
// so the next fire retries it. If even the rename fails, the claim file stays
|
|
615
|
+
// and the 1h orphan sweep collects it, which is the pre-B1 behaviour.
|
|
616
|
+
try { renameSync(claimFile, epFile); } catch { /* leave it for sweepOrphanEpisodeFiles */ }
|
|
617
|
+
}
|
|
532
618
|
}
|
|
533
619
|
} catch (e) { debugCatch(e, 'handleStop-fallback'); }
|
|
534
620
|
}
|
|
@@ -935,6 +1021,15 @@ function runSessionStartAutoMaintain(db) {
|
|
|
935
1021
|
|
|
936
1022
|
// Auto-dedup (exact): merge identical-title observations within 1h.
|
|
937
1023
|
// Catches rapid duplicate writes (same hook firing twice, race conditions).
|
|
1024
|
+
// BOTH join sides must be live (audit 2026-08-14 H-1): without the
|
|
1025
|
+
// superseded_at filters (which the fuzzy channel below always had), a row the
|
|
1026
|
+
// fuzzy pass had tombstoned could come back as `a` (a.id < b.id) and tombstone
|
|
1027
|
+
// the LIVE keeper `b` — both copies gone from every read path. Worse, a user
|
|
1028
|
+
// correction saved with supersedes=[#A] (A.superseded_by = B's NUMERIC id) has
|
|
1029
|
+
// the same title as A, so the pair (A, B) tombstoned the correction B itself
|
|
1030
|
+
// and the string 'auto-dedup' write clobbered numeric supersession chains that
|
|
1031
|
+
// citation-tracker decay hand-off and timeline re-anchoring both follow. The
|
|
1032
|
+
// UPDATE repeats the guard so a concurrent writer can't re-stamp a chain.
|
|
938
1033
|
const dupPairs = db.prepare(`
|
|
939
1034
|
SELECT a.id as keep_id, b.id as remove_id
|
|
940
1035
|
FROM observations a
|
|
@@ -943,12 +1038,14 @@ function runSessionStartAutoMaintain(db) {
|
|
|
943
1038
|
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
944
1039
|
AND COALESCE(a.compressed_into, 0) = 0
|
|
945
1040
|
AND COALESCE(b.compressed_into, 0) = 0
|
|
1041
|
+
AND a.superseded_at IS NULL
|
|
1042
|
+
AND b.superseded_at IS NULL
|
|
946
1043
|
LIMIT 20
|
|
947
1044
|
`).all();
|
|
948
1045
|
if (dupPairs.length > 0) {
|
|
949
1046
|
const removeIds = dupPairs.map(p => p.remove_id);
|
|
950
1047
|
const ph = removeIds.map(() => '?').join(',');
|
|
951
|
-
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
|
|
1048
|
+
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
|
|
952
1049
|
debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
|
|
953
1050
|
}
|
|
954
1051
|
|
|
@@ -1566,9 +1663,13 @@ async function handleUserPrompt() {
|
|
|
1566
1663
|
try {
|
|
1567
1664
|
const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
|
|
1568
1665
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
1569
|
-
const { ids, ts } = JSON.parse(raw);
|
|
1570
|
-
// Only use if written within last 10 seconds (same prompt cycle)
|
|
1571
|
-
|
|
1666
|
+
const { ids, ts, session } = JSON.parse(raw);
|
|
1667
|
+
// Only use if written within last 10 seconds (same prompt cycle) AND by this
|
|
1668
|
+
// CC session — the file is project-keyed, so a concurrent session's write
|
|
1669
|
+
// would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
|
|
1670
|
+
// Legacy payloads without `session` keep the old time-window-only behavior.
|
|
1671
|
+
if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
|
|
1672
|
+
&& !(session && ccSessionId && session !== ccSessionId)) {
|
|
1572
1673
|
for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
|
|
1573
1674
|
}
|
|
1574
1675
|
} catch { /* file may not exist — that's fine */ }
|
|
@@ -1767,9 +1868,19 @@ try {
|
|
|
1767
1868
|
case 'auto-maintain': handleAutoMaintain(); break;
|
|
1768
1869
|
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
1769
1870
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
1770
|
-
// GitHub fetch
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1871
|
+
// GitHub fetch off the SessionStart critical path, writing update-state.json so
|
|
1872
|
+
// the NEXT session's cached banner is fresh.
|
|
1873
|
+
//
|
|
1874
|
+
// F6 staging: the detached update-check worker has not run since v2.85.0
|
|
1875
|
+
// (missing from BG_EVENTS). Restore the check + banner first; re-enable the
|
|
1876
|
+
// self-replacing install in a follow-up once this path has proven itself, so a
|
|
1877
|
+
// failure in either half is attributable. Without the option, hook-update.mjs's
|
|
1878
|
+
// `allowInstall = options.allowInstall ?? !pluginMode` defaults to TRUE on a
|
|
1879
|
+
// direct / settings.json install, so fixing F6 would switch a ten-week-dormant
|
|
1880
|
+
// self-installer back on in the same release that resurrects the worker. The
|
|
1881
|
+
// module default and the installer's own guards are unchanged — install.mjs
|
|
1882
|
+
// still passes allowInstall:true for the explicit, user-invoked update.
|
|
1883
|
+
case 'update-check': await checkForUpdate({ allowInstall: false }); break;
|
|
1773
1884
|
}
|
|
1774
1885
|
} catch (err) {
|
|
1775
1886
|
// Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
|
|
@@ -1779,6 +1890,11 @@ try {
|
|
|
1779
1890
|
// lib/native-binding-hint.mjs.
|
|
1780
1891
|
const line = formatHookError(err, event, { runtimeDir: RUNTIME_DIR });
|
|
1781
1892
|
if (line) console.error(line);
|
|
1893
|
+
// stderr alone is invisible to `stats` self-observation — only the native-binding
|
|
1894
|
+
// family was persisted, so a non-binding fatal (schema drift, bad stdin shape)
|
|
1895
|
+
// could kill every dispatch-routed surface while the hook-errors log read zero
|
|
1896
|
+
// (audit 2026-08-14 M-5; same blindness one layer up from the v3.60 outage).
|
|
1897
|
+
recordHookError(`hook:${event}`, err, RUNTIME_DIR);
|
|
1782
1898
|
}
|
|
1783
1899
|
|
|
1784
1900
|
process.exit(0);
|
package/hooks/hooks.json
CHANGED
|
@@ -72,6 +72,16 @@
|
|
|
72
72
|
"timeout": 5
|
|
73
73
|
}
|
|
74
74
|
]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"matcher": "Edit|Write|NotebookEdit",
|
|
78
|
+
"hooks": [
|
|
79
|
+
{
|
|
80
|
+
"type": "command",
|
|
81
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-launcher.mjs\" scripts/post-tool-recall.js",
|
|
82
|
+
"timeout": 3
|
|
83
|
+
}
|
|
84
|
+
]
|
|
75
85
|
}
|
|
76
86
|
],
|
|
77
87
|
"Stop": [
|