@dzhechkov/harness-core 0.8.33 → 0.8.35
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/.dz-manifest.json +52 -52
- package/README.md +228 -5
- package/dist/agentdb-index.d.ts +39 -7
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +217 -23
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +197 -6
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +858 -46
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +16 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +115 -13
- package/dist/operations.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +72 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +150 -4
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/release.d.ts +72 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +236 -19
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -4
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +51 -51
- package/src/agentdb-index.ts +223 -24
- package/src/apply-leg.ts +875 -46
- package/src/index.ts +18 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +117 -14
- package/src/publish-sibling-drift.ts +209 -4
- package/src/release.ts +263 -17
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +157 -5
package/dist/apply-leg.js
CHANGED
|
@@ -26,9 +26,94 @@
|
|
|
26
26
|
*
|
|
27
27
|
* @packageDocumentation
|
|
28
28
|
*/
|
|
29
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
-
import {
|
|
29
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
30
|
+
import { connect as netConnect } from 'node:net';
|
|
31
|
+
// `node:os` is NOT one of the modules `countIoImports` (core-boundary.ts) tracks — free to import
|
|
32
|
+
// (core-boundary.test.ts's ratchet only counts fs/child_process/https). `probeApplyLeg`'s temp probe
|
|
33
|
+
// cwd reuses the existing top-level 'node:fs' import above (mkdtempSync/rmSync added to that SAME
|
|
34
|
+
// import statement, not a new one) so the ratchet stays at its pinned files:63 imports:69.
|
|
35
|
+
import { tmpdir } from 'node:os';
|
|
36
|
+
import { join, resolve } from 'node:path';
|
|
31
37
|
import { hookCommandsOf } from './managed-hooks.js';
|
|
38
|
+
import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync } from './patterns.js';
|
|
39
|
+
/**
|
|
40
|
+
* FR-6 (feature `hook-recall-hybrid-parity`, ADR-001 C-4): send ONE `op: recall` probe to a LIVE
|
|
41
|
+
* embed daemon socket and report the `engine` it answers with (`'hybrid'` | `'cosine-fallback'`) —
|
|
42
|
+
* `dz doctor` prints this so an operator can SEE which engine is actually serving prompts, rather
|
|
43
|
+
* than trusting the daemon's mere presence. Honest-degrade contract, matching every other doctor
|
|
44
|
+
* probe: a non-socket path (e.g. a plain file, as every non-live doctor fixture in this repo uses),
|
|
45
|
+
* a connection error, an unparsable reply, or a timeout all resolve to `undefined` — NEVER a thrown
|
|
46
|
+
* error, and never distinguishable from "no daemon" in the caller's output (the existing "socket
|
|
47
|
+
* present/absent" line already carries that half of the truth).
|
|
48
|
+
*
|
|
49
|
+
* `timeoutMs` defaults to 1000 ms — comfortably above the documented `HOOK_RECALL_BUDGET_MS` default
|
|
50
|
+
* (500 ms): under that default, a cold `recallHybrid` semantic leg routinely exceeds the budget in
|
|
51
|
+
* this environment (MEASURED — see the manifest's NFR-1 discussion), so the daemon's OWN answer
|
|
52
|
+
* time is closer to ~500-550 ms than to the socket round-trip cost alone; a shorter probe timeout
|
|
53
|
+
* would silently miss a live, correctly-answering daemon and report no engine at all.
|
|
54
|
+
*
|
|
55
|
+
* AM-8 (fix round 1): lives HERE, not in `operations.ts` — this module already owns the daemon's
|
|
56
|
+
* wire protocol (`recallHookSource`/`embedDaemonSource`'s generated `op: recall` handshake) and its
|
|
57
|
+
* socket-path resolution; `operations.ts`'s `runDoctor` reaches it via a dynamic `import()`
|
|
58
|
+
* (matching its existing `embed-socket-path.js` import one line above the call site) rather than
|
|
59
|
+
* duplicating a second, independent `node:net` IO surface in a file whose job is orchestration, not
|
|
60
|
+
* protocol.
|
|
61
|
+
*/
|
|
62
|
+
export function probeRecallEngine(socketPath, timeoutMs = 1000) {
|
|
63
|
+
return new Promise((resolvePromise) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
const done = (v) => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
try {
|
|
70
|
+
sock.destroy();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* already gone */
|
|
74
|
+
}
|
|
75
|
+
resolvePromise(v);
|
|
76
|
+
};
|
|
77
|
+
let sock;
|
|
78
|
+
try {
|
|
79
|
+
sock = netConnect(socketPath);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
resolvePromise(undefined);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const timer = setTimeout(() => done(undefined), timeoutMs);
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
let buf = '';
|
|
88
|
+
sock.on('connect', () => {
|
|
89
|
+
try {
|
|
90
|
+
sock.write(`${JSON.stringify({ op: 'recall', prompt: 'dz doctor probe', limit: 1 })}\n`);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
done(undefined);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
sock.on('data', (chunk) => {
|
|
97
|
+
buf += chunk.toString('utf-8');
|
|
98
|
+
const nl = buf.indexOf('\n');
|
|
99
|
+
if (nl === -1)
|
|
100
|
+
return;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
try {
|
|
103
|
+
const msg = JSON.parse(buf.slice(0, nl));
|
|
104
|
+
// Codex round-3: only the protocol's own vocabulary is reported; anything else is "unknown" (undefined)
|
|
105
|
+
done(msg.engine === 'hybrid' || msg.engine === 'cosine-fallback' || msg.engine === 'none' ? msg.engine : undefined);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
done(undefined);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
sock.on('error', () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
done(undefined);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
32
117
|
/**
|
|
33
118
|
* Version stamped into BOTH generated helper files as `// dz-apply-leg-version: N` (line 2, right
|
|
34
119
|
* after the shebang). Bump on ANY change to {@link recallHookSource} or {@link embedDaemonSource}'s
|
|
@@ -46,8 +131,70 @@ import { hookCommandsOf } from './managed-hooks.js';
|
|
|
46
131
|
* compiled module), the daemon writes a `.dz/embed.sock.path` pointer when it picks the tmpdir-short
|
|
47
132
|
* branch, and `ready` is now printed only after `existsSync(SOCKET)` confirms the bind actually
|
|
48
133
|
* landed (previously logged unconditionally, before `listen` even ran).
|
|
134
|
+
*
|
|
135
|
+
* Bumped 4→5 (feature `hook-recall-hybrid-parity`, ADR-001 D1/D2): the daemon's `op: recall`
|
|
136
|
+
* handler now tries core's `recallHybrid` FIRST — under a time budget (`HOOK_RECALL_BUDGET_MS`,
|
|
137
|
+
* default 500 ms) — via the SAME `CORE_DIST_DIR` + `loadCoreModule` mechanism the hook already
|
|
138
|
+
* used only for its policy modules; on budget overrun, engine error, or no resolvable core module
|
|
139
|
+
* it falls back to today's brute-force cosine, honestly labelled `engine: 'cosine-fallback'` with a
|
|
140
|
+
* `reason`. The hook now reads `engine`/`reason` off the daemon's reply (stderr-only, never
|
|
141
|
+
* context) and applies its relevance floor to the NEW `score` format when `engine === 'hybrid'`,
|
|
142
|
+
* preserving today's cosine-calibrated floor unchanged for the `cosine-fallback` path.
|
|
143
|
+
*
|
|
144
|
+
* Bumped 5→6 (`hook-recall-hybrid-parity`, fix round 1 — AM-1/AM-2/AM-3/AM-5): the daemon now
|
|
145
|
+
* (a) fires a fire-and-forget engine warm-up before `listen()` (AM-1) so the first REAL `op: recall`
|
|
146
|
+
* is less likely to pay a cold `resolveAgentdbEmbedder` init; (b) arms the budget timer BEFORE
|
|
147
|
+
* `loadCoreModule()`, not after (AM-2, wall clock from request receipt); (c) treats ANY failure
|
|
148
|
+
* past the budget race — a malformed hit, `patternRecordId()` throwing — as an honest cosine
|
|
149
|
+
* fallback rather than a bare protocol error (AM-3); (d) reports the RAW core RRF score, unchanged,
|
|
150
|
+
* instead of a locally re-normalized [0,1] value (AM-5) — the hook's own `HOOK_SCORE_FLOOR` default
|
|
151
|
+
* moved from `0.01` to `0.005` to match (see that constant's own comment for the measurement).
|
|
152
|
+
*
|
|
153
|
+
* Bumped 6→7 (feature `apply-leg-install-root`, ADR-001 D1): both generated files now resolve
|
|
154
|
+
* `PROJECT` install-root-first (`INSTALL_ROOT` = this file's own location, when it owns a `.dz/`)
|
|
155
|
+
* instead of trusting a foreign session's `CLAUDE_PROJECT_DIR`/cwd (issue #2) — the hook's own
|
|
156
|
+
* `[dz-recall]` diagnostic line also gains `root=<path> (install|env|cwd)`.
|
|
157
|
+
*
|
|
158
|
+
* Bumped 7→8 (`apply-leg-install-root`, fix round 1, AM-7 HIGH — real regression MEASURED via
|
|
159
|
+
* `retro-debt-hook.test.ts` going 4/5 red on the v7 hub helper): `PROJECT` install-root-first is
|
|
160
|
+
* correct for the STORE (pattern db, socket, daemon script) but WRONG for a per-session artifact —
|
|
161
|
+
* the narrated-error retro-debt sentinel (`retro-pending.json`) is written by the INVOKING
|
|
162
|
+
* SESSION's own Stop hook under `CLAUDE_PROJECT_DIR`, not under wherever the hook happens to be
|
|
163
|
+
* installed; a shared $HOME install made the hook look for a foreign session's sentinel under the
|
|
164
|
+
* install root and silently drop every session's own debt confrontation. Split: `PROJECT` (install-
|
|
165
|
+
* root-first) stays the STORE root; a new `SESSION_ROOT` (`CLAUDE_PROJECT_DIR || cwd()`, the
|
|
166
|
+
* pre-feature resolution, unchanged) is the root for `RETRO_PENDING` — the ONE per-session file this
|
|
167
|
+
* hook reads (every other `PROJECT`-derived path in this file names the store, the daemon, or the
|
|
168
|
+
* harness-core install, confirmed by grep against every `path.join(PROJECT, …)` site). The diag line
|
|
169
|
+
* gains `session=<path>` alongside the existing `root=<path> (…)`.
|
|
170
|
+
*
|
|
171
|
+
* Bumped 8→9 (feature `apply-leg-never-silent`, ADR-001 D2, FR-1): every early return in `main()`
|
|
172
|
+
* now prints `[dz-recall] skipped reason=<store-not-found|socket-absent|core-unavailable|
|
|
173
|
+
* empty-prompt|no-hits> root=<path> (…) session=<path>` on stderr before returning — the hook used
|
|
174
|
+
* to exit silently on every one of these paths, indistinguishable (from stderr alone) from a
|
|
175
|
+
* correctly-quiet "nothing relevant" outcome. `embedDaemonSource`'s own bytes are UNCHANGED by this
|
|
176
|
+
* bump; the shared version number still advances because both helpers are upgraded as one unit by
|
|
177
|
+
* `dz setup`/`applyLegStatus`.
|
|
178
|
+
*
|
|
179
|
+
* Bumped 9→10 (`apply-leg-never-silent`, fix round 1 — cross-model review AM-3/AM-6): (a) the hook
|
|
180
|
+
* now tags its `op: recall` request with `probe: <bool>` (true only when
|
|
181
|
+
* `DZ_HOOK_LIVENESS_PROBE=1`, the env {@link "./operations.js".probeHookLiveness} already stamps on
|
|
182
|
+
* every live-probe spawn) so the daemon can tell a genuine session prompt apart from
|
|
183
|
+
* `probeApplyLeg`'s own beacon query — AM-3: a beacon written for the ~8s of a doctor/parity probe
|
|
184
|
+
* used to be recallable by ANY concurrent real prompt in the SAME project, a probe-only fixture
|
|
185
|
+
* leaking into a real session's context; (b) `askDaemon`'s every failure path used to collapse into
|
|
186
|
+
* one `undefined`, forcing the hook's own `skip('socket-absent')` call regardless of what actually
|
|
187
|
+
* went wrong — AM-6: it now returns a tagged `{error: 'socket-absent'|'connect-refused'|
|
|
188
|
+
* 'daemon-timeout'|'bad-reply'}` so the stderr reason names the ACTUAL failure (no socket file vs a
|
|
189
|
+
* non-socket file at that path vs a listener that never replies vs a listener that replies with
|
|
190
|
+
* something unparseable/shapeless). `embedDaemonSource`'s bytes also change for AM-3: `loadPatterns`
|
|
191
|
+
* now reads each row's `domain` out of the SAME metadata JSON `dzIdOf`/`quarantinedOf` already
|
|
192
|
+
* parse (the vector mirror carries no separate domain column), and both `hybridRecall`'s hits and
|
|
193
|
+
* the cosine-fallback `scored` array are filtered to exclude `domain === 'apply-leg-probe'` unless
|
|
194
|
+
* the request carried `probe: true` — a probe's own beacon still needs to reach ITS query, only a
|
|
195
|
+
* REAL prompt must never see it.
|
|
49
196
|
*/
|
|
50
|
-
export const APPLY_LEG_VERSION =
|
|
197
|
+
export const APPLY_LEG_VERSION = 10;
|
|
51
198
|
/**
|
|
52
199
|
* Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
|
|
53
200
|
* `writerVersionOf` (which floors an absent stamp at `0`), this returns `-1` for "no stamp at
|
|
@@ -137,7 +284,24 @@ const crypto = require('node:crypto');
|
|
|
137
284
|
const os = require('node:os');
|
|
138
285
|
const { pathToFileURL } = require('node:url');
|
|
139
286
|
|
|
140
|
-
|
|
287
|
+
// FR-1 (ADR-001 D1, apply-leg-install-root): resolve OUR OWN store from where this hook is
|
|
288
|
+
// INSTALLED, not from whatever project the invoking session happens to be in — a hook installed at
|
|
289
|
+
// $HOME (a common single-machine layout: \`dz setup --target claude --memory agentdb --project
|
|
290
|
+
// $HOME\`, expecting the leg everywhere) used to silently look up a DIFFERENT project's \`.dz/\`
|
|
291
|
+
// whenever CLAUDE_PROJECT_DIR pointed elsewhere (issue #2, MEASURED). Precedent:
|
|
292
|
+
// claude-hooks-assets.ts's \`path.resolve(__dirname, '..', '..')\` for the destructive-guard hook.
|
|
293
|
+
// Order: INSTALL_ROOT (if it owns a \`.dz/\`) -> CLAUDE_PROJECT_DIR -> cwd.
|
|
294
|
+
const INSTALL_ROOT = path.resolve(__dirname, '..', '..');
|
|
295
|
+
const ROOT_SOURCE = fs.existsSync(path.join(INSTALL_ROOT, '.dz')) ? 'install' : (process.env.CLAUDE_PROJECT_DIR ? 'env' : 'cwd');
|
|
296
|
+
const PROJECT = ROOT_SOURCE === 'install' ? INSTALL_ROOT : (process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
297
|
+
// AM-7 (fix round 1, apply-leg-install-root): PROJECT above is the STORE root (install-first) — a
|
|
298
|
+
// pattern db shared across every session at a $HOME install is correctly install-scoped. A
|
|
299
|
+
// per-session ARTIFACT is the opposite: the retro-debt sentinel is written by THIS SESSION's own
|
|
300
|
+
// Stop hook under its own CLAUDE_PROJECT_DIR, so looking it up under a foreign install root finds
|
|
301
|
+
// nothing (or, worse, another session's leftover file) and silently drops the confrontation.
|
|
302
|
+
// SESSION_ROOT is the pre-feature resolution, unchanged — the root for any file that belongs to the
|
|
303
|
+
// INVOKING session rather than to the store.
|
|
304
|
+
const SESSION_ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
141
305
|
const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
|
|
142
306
|
|
|
143
307
|
// embed-socket-short-path (FR-1/FR-2): byte-for-byte the same logic as \`resolveEmbedSocketPath\` /
|
|
@@ -173,9 +337,40 @@ function resolveEffectiveEmbedSocketPath(projectRoot, env) {
|
|
|
173
337
|
return pointer !== undefined && fs.existsSync(pointer) ? { path: pointer, reason: 'tmpdir-short' } : resolved;
|
|
174
338
|
}
|
|
175
339
|
const SOCKET = resolveEffectiveEmbedSocketPath(PROJECT, process.env).path;
|
|
340
|
+
// AM-3 (fix round 1, apply-leg-never-silent): set ONLY by probeHookLiveness (operations.ts) on
|
|
341
|
+
// every live-probe spawn — never by a real Claude Code session. Rides into the daemon's op:recall
|
|
342
|
+
// request as \`probe\` so the daemon can admit the probe's OWN beacon (domain=apply-leg-probe)
|
|
343
|
+
// without ever surfacing it to a concurrent real prompt in the same project.
|
|
344
|
+
const IS_LIVENESS_PROBE = process.env.DZ_HOOK_LIVENESS_PROBE === '1';
|
|
176
345
|
const USAGE_LOG = process.env.DZ_RECALL_USAGE_LOG || path.join(PROJECT, '.dz', 'recall-usage.jsonl');
|
|
346
|
+
// Measured (2026-09-14, apply-leg-socket.test.ts): an ordinary hook round-trip (spawn + one socket
|
|
347
|
+
// op) took 81-121 ms; 800 ms leaves a wide margin for a loaded daemon while still bounding the AM-2
|
|
348
|
+
// worst case — a daemon synchronously blocked never replies at all, so THIS timeout (not the
|
|
349
|
+
// daemon's own internal budget race, which cannot preempt synchronous work) is what actually
|
|
350
|
+
// rescues the hook from hanging.
|
|
177
351
|
const TIMEOUT_MS = Number(process.env.DZ_RECALL_HOOK_TIMEOUT_MS || 800);
|
|
178
352
|
|
|
353
|
+
// FR-5 (hook-recall-hybrid-parity, ADR-001 D2): the RRF-based \`score\` the daemon returns for
|
|
354
|
+
// \`engine: 'hybrid'\` is NOT on the cosine scale DEFAULT_RECALL_FLOORS (recall-hook-policy.ts) was
|
|
355
|
+
// calibrated on — applying the cosine floor to an RRF score would either admit everything or cut
|
|
356
|
+
// everything, so the hybrid path gets its OWN floor, applied to BOTH languages alike (the RRF score
|
|
357
|
+
// carries no language-baseline shift the way raw cosine did).
|
|
358
|
+
//
|
|
359
|
+
// AM-5 (fix round 1), MEASURED not a placeholder: recallHybrid(RRF_K=60) over a live 14-lesson
|
|
360
|
+
// fixture (this environment, 2026-09-14 — reproducer in the manifest's Fix-round 1 section) shows
|
|
361
|
+
// raw RRF score is only WEAKLY discriminating per-hit: an exact-lexical-match hit scored 0.03252
|
|
362
|
+
// (both legs agree at rank 0), but a genuinely IRRELEVANT query ("xkcd banana quantum toaster
|
|
363
|
+
// nonsense") still returned a top hit at 0.01639 — HIGHER than several truly relevant tail hits in
|
|
364
|
+
// OTHER queries (0.01471-0.01538). This is structural, not a fixture artifact: RRF encodes RANK,
|
|
365
|
+
// not similarity, and a nearest-neighbor search always returns SOME top-1 even for a garbage query.
|
|
366
|
+
// A raw-score floor therefore cannot cleanly separate signal from noise at the per-hit level the
|
|
367
|
+
// way the cosine floor does — true filtering here has to come from \`limit\` and \`selectHookHits\`'s
|
|
368
|
+
// own budget, not from this number. The floor's honest job is only to reject a DEGENERATE score
|
|
369
|
+
// (zero/negative/NaN from a malformed hit), so it is set well BELOW the measured noise floor
|
|
370
|
+
// (0.01471) rather than attempting to rank-filter — deliberately permissive, matching ADR-001 D2's
|
|
371
|
+
// stated intent that an exact lexical match (FR-4) must never be defeated by an unmeasured cutoff.
|
|
372
|
+
const HOOK_SCORE_FLOOR = Number(process.env.DZ_RECALL_HOOK_SCORE_FLOOR || 0.005);
|
|
373
|
+
|
|
179
374
|
const safe = (fn, fb) => {
|
|
180
375
|
try {
|
|
181
376
|
return fn();
|
|
@@ -239,7 +434,9 @@ async function loadPolicy() {
|
|
|
239
434
|
* - stale / other-session sentinel ⇒ '' (the debt belongs to a dead session; retro collected it);
|
|
240
435
|
* - core module absent or old (no directive exports) ⇒ '' — inert, NEVER-BLOCK.
|
|
241
436
|
*/
|
|
242
|
-
|
|
437
|
+
// AM-7 (fix round 1): SESSION_ROOT, not PROJECT — the sentinel is a per-session artifact (see the
|
|
438
|
+
// SESSION_ROOT comment above).
|
|
439
|
+
const RETRO_PENDING = path.join(SESSION_ROOT, '.dz', 'retro-pending.json');
|
|
243
440
|
async function retroDebtDirective(payload) {
|
|
244
441
|
if (!fs.existsSync(RETRO_PENDING)) return '';
|
|
245
442
|
const sentinel = safe(() => JSON.parse(fs.readFileSync(RETRO_PENDING, 'utf-8')), undefined);
|
|
@@ -291,9 +488,16 @@ function readLogTail(chain, file) {
|
|
|
291
488
|
}, chain && chain.EMPTY_LOG_TAIL);
|
|
292
489
|
}
|
|
293
490
|
|
|
491
|
+
// FR-6 (hook-recall-hybrid-parity): the reply now carries \`engine\`/\`reason\` alongside \`hits\` —
|
|
492
|
+
// returned as a small object rather than the bare hit array, so the caller can apply the RIGHT
|
|
493
|
+
// floor (FR-5) and print the engine to stderr ONLY (never into the injected context, FR-2).
|
|
494
|
+
// AM-6 (fix round 1): every failure used to collapse into one \`undefined\`, forcing the caller's
|
|
495
|
+
// \`skip('socket-absent')\` regardless of whether the socket was truly absent, refused the connect,
|
|
496
|
+
// never replied, or replied with garbage. Each branch now tags its OWN reason so the stderr line
|
|
497
|
+
// (and, through it, \`probeApplyLeg\`'s parsed reason) names what actually happened.
|
|
294
498
|
function askDaemon(prompt) {
|
|
295
499
|
return new Promise((resolve) => {
|
|
296
|
-
if (!fs.existsSync(SOCKET)) return resolve(
|
|
500
|
+
if (!fs.existsSync(SOCKET)) return resolve({ error: 'socket-absent' });
|
|
297
501
|
let settled = false;
|
|
298
502
|
const done = (v) => {
|
|
299
503
|
if (settled) return;
|
|
@@ -302,21 +506,29 @@ function askDaemon(prompt) {
|
|
|
302
506
|
resolve(v);
|
|
303
507
|
};
|
|
304
508
|
const sock = net.connect(SOCKET);
|
|
305
|
-
const timer = setTimeout(() => done(
|
|
509
|
+
const timer = setTimeout(() => done({ error: 'daemon-timeout' }), TIMEOUT_MS);
|
|
306
510
|
timer.unref?.();
|
|
307
511
|
let buf = '';
|
|
308
|
-
sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8 }) + '\\n'));
|
|
512
|
+
sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8, probe: IS_LIVENESS_PROBE }) + '\\n'));
|
|
309
513
|
sock.on('data', (chunk) => {
|
|
310
514
|
buf += chunk.toString('utf8');
|
|
311
515
|
const nl = buf.indexOf('\\n');
|
|
312
516
|
if (nl === -1) return;
|
|
313
517
|
clearTimeout(timer);
|
|
314
518
|
const msg = safe(() => JSON.parse(buf.slice(0, nl)), undefined);
|
|
315
|
-
|
|
519
|
+
if (msg && Array.isArray(msg.hits)) {
|
|
520
|
+
done({
|
|
521
|
+
hits: msg.hits,
|
|
522
|
+
engine: typeof msg.engine === 'string' ? msg.engine : undefined,
|
|
523
|
+
reason: typeof msg.reason === 'string' ? msg.reason : undefined,
|
|
524
|
+
});
|
|
525
|
+
} else {
|
|
526
|
+
done({ error: 'bad-reply' });
|
|
527
|
+
}
|
|
316
528
|
});
|
|
317
529
|
sock.on('error', () => {
|
|
318
530
|
clearTimeout(timer);
|
|
319
|
-
done(
|
|
531
|
+
done({ error: 'connect-refused' });
|
|
320
532
|
});
|
|
321
533
|
});
|
|
322
534
|
}
|
|
@@ -324,6 +536,13 @@ function askDaemon(prompt) {
|
|
|
324
536
|
const REVIVE_LOCK = path.join(PROJECT, '.dz', 'embed-daemon.lock');
|
|
325
537
|
const REVIVE_LOCK_FRESH_MS = 120_000; // model load takes ~45s; don't respawn while one is coming up
|
|
326
538
|
function reviveDaemon() {
|
|
539
|
+
// AM-7 (fix round 1, test-only): a test that probes the SAME dead fixture multiple times in quick
|
|
540
|
+
// succession (probeApplyLeg directly, then again through runDoctor, then again through a
|
|
541
|
+
// 'dz parity' subprocess) used to race against this very self-heal — the first probe's revive
|
|
542
|
+
// could finish loading a real daemon before the second or third probe ran, flipping
|
|
543
|
+
// "socket-absent" into "hybrid"/"cosine-fallback" non-deterministically. No production session
|
|
544
|
+
// ever sets this.
|
|
545
|
+
if (process.env.DZ_RECALL_NO_REVIVE === '1') return;
|
|
327
546
|
// CROSS-PROCESS lock: every prompt runs a fresh hook process, so a per-process flag let 20 queued
|
|
328
547
|
// prompts spawn 20 daemons while the first was still loading its model (Codex #5). A lockfile with
|
|
329
548
|
// a freshness window means at most one spawn per window, machine-wide.
|
|
@@ -489,6 +708,17 @@ function emitContext(context) {
|
|
|
489
708
|
);
|
|
490
709
|
}
|
|
491
710
|
|
|
711
|
+
// FR-1 (ADR-001 D2, apply-leg-never-silent): every silent early exit below now names WHY, on
|
|
712
|
+
// stderr, one line, same shape as the existing \`[dz-recall] engine=…\` diagnostic. Exit code stays
|
|
713
|
+
// 0 — a broken/empty/quiet hook must never fail a prompt (NEVER-BLOCK, unchanged). The reason is
|
|
714
|
+
// for TWO readers, neither of which is "the user watching Claude Code's own transcript" (FR-2's
|
|
715
|
+
// own manifest names why that channel does not apply to this hook): (1) \`dz doctor\`'s live probe,
|
|
716
|
+
// which spawns this exact command as a child process and reads ITS OWN child's stderr directly —
|
|
717
|
+
// unmediated by Claude Code's UI, so the redirect policy of any particular hook EVENT is moot; and
|
|
718
|
+
// (2) a human running the command by hand from a terminal, who sees stderr exactly as printed.
|
|
719
|
+
const skip = (reason) =>
|
|
720
|
+
safe(() => process.stderr.write(\`[dz-recall] skipped reason=\${reason} root=\${PROJECT} (\${ROOT_SOURCE}) session=\${SESSION_ROOT}\\n\`));
|
|
721
|
+
|
|
492
722
|
async function main() {
|
|
493
723
|
const raw = readStdin();
|
|
494
724
|
const payload = safe(() => JSON.parse(String(raw || '').trim()), undefined);
|
|
@@ -499,13 +729,34 @@ async function main() {
|
|
|
499
729
|
// sentinel is absent this is one existsSync and debt === '' (byte-identical outputs to before).
|
|
500
730
|
const debt = await retroDebtDirective(payload);
|
|
501
731
|
|
|
502
|
-
if (prompt === '')
|
|
732
|
+
if (prompt === '') {
|
|
733
|
+
skip('empty-prompt');
|
|
734
|
+
return emitContext(debt);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// FR-1: the most fundamental silent failure (issue #2) — no \`.dz/\` at all under the resolved
|
|
738
|
+
// PROJECT root. Checked BEFORE the policy/daemon legs below: a missing store makes every
|
|
739
|
+
// downstream question ("is the daemon alive?") moot, and printing THIS reason first is what let
|
|
740
|
+
// the original issue's symptom (four green checks, a store that was never there) be diagnosed
|
|
741
|
+
// from stderr alone.
|
|
742
|
+
if (!fs.existsSync(path.join(PROJECT, '.dz'))) {
|
|
743
|
+
skip('store-not-found');
|
|
744
|
+
return emitContext(debt);
|
|
745
|
+
}
|
|
503
746
|
|
|
504
747
|
const policy = await loadPolicy();
|
|
505
|
-
if (!policy)
|
|
748
|
+
if (!policy) {
|
|
749
|
+
skip('core-unavailable');
|
|
750
|
+
return emitContext(debt);
|
|
751
|
+
}
|
|
506
752
|
|
|
507
|
-
const
|
|
508
|
-
|
|
753
|
+
const daemonReply = await askDaemon(prompt);
|
|
754
|
+
// AM-6: the tagged reason IS the diagnosis now — 'socket-absent' (no file), 'connect-refused' (a
|
|
755
|
+
// file exists but nothing answers like a daemon), 'daemon-timeout' (something answers, never in
|
|
756
|
+
// time) and 'bad-reply' (answers, unparseable/shapeless) are four DIFFERENT defects with four
|
|
757
|
+
// different remedies; collapsing them back into one string is exactly the finding this fixes.
|
|
758
|
+
if (daemonReply.error) {
|
|
759
|
+
skip(daemonReply.error);
|
|
509
760
|
// SELF-HEAL (2026-07-28): the daemon is started at SessionStart only, so when it dies mid-way
|
|
510
761
|
// through a long-lived session NOTHING restarts it — the apply leg was silently dead for 19
|
|
511
762
|
// days (MEASURED: recall-usage.jsonl last record 2026-07-09, socket absent). Spawn it
|
|
@@ -513,9 +764,22 @@ async function main() {
|
|
|
513
764
|
reviveDaemon();
|
|
514
765
|
return emitContext(debt);
|
|
515
766
|
}
|
|
516
|
-
|
|
767
|
+
const { hits, engine, reason } = daemonReply;
|
|
768
|
+
// FR-6/FR-2: the engine (and, on fallback, why) is the caller's business, not the model's — it
|
|
769
|
+
// NEVER rides into additionalContext, only stderr, which Claude Code does not read as context.
|
|
770
|
+
if (typeof engine === 'string') {
|
|
771
|
+
safe(() => process.stderr.write(\`[dz-recall] engine=\${engine}\${reason ? \` reason=\${reason}\` : ''} root=\${PROJECT} (\${ROOT_SOURCE}) session=\${SESSION_ROOT}\\n\`));
|
|
772
|
+
}
|
|
773
|
+
if (hits.length === 0) {
|
|
774
|
+
skip('no-hits');
|
|
775
|
+
return emitContext(debt); // daemon alive, nothing relevant — silence is correct
|
|
776
|
+
}
|
|
517
777
|
|
|
518
|
-
|
|
778
|
+
// FR-5 (ADR-001 D2): a hybrid-engine reply carries an RRF-based score — its OWN floor, applied to
|
|
779
|
+
// both languages. A cosine-fallback reply (or an old daemon that never sent \`engine\` at all)
|
|
780
|
+
// keeps today's cosine-calibrated DEFAULT_RECALL_FLOORS untouched.
|
|
781
|
+
const floorOpts = engine === 'hybrid' ? { floors: { ru: HOOK_SCORE_FLOOR, en: HOOK_SCORE_FLOOR } } : {};
|
|
782
|
+
const selection = policy.selectHookHits(prompt, hits, floorOpts);
|
|
519
783
|
// lesson-quarantine AM-2: an excluded hypothesis is logged, never a silent context shrink.
|
|
520
784
|
if (typeof selection.quarantinedExcluded === 'number' && selection.quarantinedExcluded > 0) {
|
|
521
785
|
try {
|
|
@@ -566,13 +830,22 @@ export function resolveIdleMs(raw) {
|
|
|
566
830
|
}
|
|
567
831
|
/**
|
|
568
832
|
* Generate `.claude/helpers/dz-embed-daemon.mjs`. Behaviourally identical to the pre-existing
|
|
569
|
-
* hand-committed hub file except for: the version stamp (new, line 2)
|
|
570
|
-
*
|
|
833
|
+
* hand-committed hub file except for: the version stamp (new, line 2), `resolveDeps()`, which
|
|
834
|
+
* tries `@huggingface/transformers` before falling back to `@xenova/transformers` — AM-3,
|
|
571
835
|
* dz-harness-hub issue #10 defect 3: `agentdb >= 3.0.0-alpha` depends on the former, and an older
|
|
572
836
|
* agentdb install still carries the latter, so probing only one name silently starved the daemon
|
|
573
|
-
* on either side of that agentdb version boundary
|
|
837
|
+
* on either side of that agentdb version boundary — and (feature `hook-recall-hybrid-parity`,
|
|
838
|
+
* ADR-001 D1) the `op: recall` handler, which now tries core's `recallHybrid` under a time budget
|
|
839
|
+
* before falling back to the brute-force cosine below.
|
|
840
|
+
*
|
|
841
|
+
* `coreDistDir` (new parameter, ADR-001 D1) is baked in exactly like {@link recallHookSource}'s own
|
|
842
|
+
* parameter of the same name — the FIRST resolve candidate for `loadCoreModule`. `null` (the
|
|
843
|
+
* default, and what every existing zero-arg call site gets) is the same PORTABLE marker
|
|
844
|
+
* `recallHookSource(null)` uses: `loadCoreModule` falls through to the project-relative fallback
|
|
845
|
+
* candidates, resolved from `DZ_PROJECT_ROOT`/`cwd()` at daemon RUNTIME, which is correct in any
|
|
846
|
+
* clone and for any consumer whose `harness-core` install is reachable under its own project tree.
|
|
574
847
|
*/
|
|
575
|
-
export function embedDaemonSource() {
|
|
848
|
+
export function embedDaemonSource(coreDistDir = null) {
|
|
576
849
|
return `#!/usr/bin/env node
|
|
577
850
|
// dz-apply-leg-version: ${APPLY_LEG_VERSION}
|
|
578
851
|
/**
|
|
@@ -594,11 +867,18 @@ export function embedDaemonSource() {
|
|
|
594
867
|
* - it exits on SIGINT/SIGTERM/SIGHUP and unlinks its socket.
|
|
595
868
|
*
|
|
596
869
|
* PROTOCOL — newline-delimited JSON over a unix socket:
|
|
597
|
-
* → {"op":"recall","prompt":"…","limit":8}
|
|
870
|
+
* → {"op":"recall","prompt":"…","limit":8} ← {"hits":[{"dzId","pattern","score","domain"}],"engine":"hybrid"|"cosine-fallback","reason"?}
|
|
598
871
|
* → {"op":"ping"} ← {"ok":true,"model":"…","uptimeMs":N}
|
|
599
872
|
* → {"op":"stop"} ← {"ok":true} (then exits)
|
|
600
873
|
*
|
|
601
|
-
* \`
|
|
874
|
+
* ADR-001 (feature \`hook-recall-hybrid-parity\`): \`op: recall\` first tries core's \`recallHybrid\`
|
|
875
|
+
* (same engine \`dz recall\` uses) under \`HOOK_RECALL_BUDGET_MS\` (default 500 ms, < the hook's own
|
|
876
|
+
* 800 ms timeout); on budget overrun, engine error, or no resolvable core module it falls back to
|
|
877
|
+
* the brute-force cosine below. \`score\` on \`engine:"hybrid"\` is the RAW core RRF score, UNCHANGED
|
|
878
|
+
* (AM-5, fix round 1) — the exact same number \`dz recall --json\` reports as \`relevance\`, never
|
|
879
|
+
* locally re-normalized; on \`engine:"cosine-fallback"\` it is COSINE RELEVANCE in [0,1] as before —
|
|
880
|
+
* two DIFFERENT scales, never the teaching reward either way. The caller applies the right floor
|
|
881
|
+
* for whichever scale \`engine\` names.
|
|
602
882
|
*/
|
|
603
883
|
|
|
604
884
|
import { createServer } from 'node:net';
|
|
@@ -608,13 +888,77 @@ import { createRequire } from 'node:module';
|
|
|
608
888
|
import { connect } from 'node:net';
|
|
609
889
|
import { createHash } from 'node:crypto';
|
|
610
890
|
import { tmpdir } from 'node:os';
|
|
891
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
611
892
|
|
|
612
893
|
const log = (...a) => console.error('[dz-embed]', ...a);
|
|
613
894
|
|
|
614
895
|
/** Never let a diagnostic reach stdout — the hook that spawns us may be parsing it. */
|
|
615
896
|
console.log = (...a) => console.error(...a);
|
|
616
897
|
|
|
617
|
-
|
|
898
|
+
// FR-4 (ADR-001 D1, apply-leg-install-root): the SAME install-root-first order the hook uses (see
|
|
899
|
+
// recallHookSource's own PROJECT comment) — DZ_PROJECT_ROOT stays the TOP override for the daemon
|
|
900
|
+
// (a caller that explicitly names a project root always wins), then INSTALL_ROOT (this file's own
|
|
901
|
+
// location, when it owns a \`.dz/\`), then cwd.
|
|
902
|
+
const INSTALL_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
903
|
+
const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? (existsSync(join(INSTALL_ROOT, '.dz')) ? INSTALL_ROOT : process.cwd());
|
|
904
|
+
|
|
905
|
+
// ADR-001 (hook-recall-hybrid-parity, D1): the SAME candidate-list resolution the hook uses for its
|
|
906
|
+
// own policy modules — the baked \`coreDistDir\` first (a real install's absolute dist path), then
|
|
907
|
+
// project-relative fallbacks resolved from PROJECT at RUNTIME. \`null\` (the hub's own portable
|
|
908
|
+
// marker, matching \`recallHookSource(null)\`) skips straight to the fallbacks. Loaded ONCE and
|
|
909
|
+
// memoized (\`coreModulePromise\`) — a fresh \`import()\` per recall would defeat FR-3's "opened once".
|
|
910
|
+
const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
|
|
911
|
+
let coreModulePromise;
|
|
912
|
+
function loadCoreModule() {
|
|
913
|
+
if (coreModulePromise !== undefined) return coreModulePromise;
|
|
914
|
+
coreModulePromise = (async () => {
|
|
915
|
+
const candidates = [
|
|
916
|
+
...(CORE_DIST_DIR ? [join(CORE_DIST_DIR, 'index.js')] : []),
|
|
917
|
+
join(PROJECT, 'node_modules', '@dzhechkov', 'harness-core', 'dist', 'index.js'),
|
|
918
|
+
join(PROJECT, 'packages', '@dzhechkov', 'harness-core', 'dist', 'index.js'),
|
|
919
|
+
];
|
|
920
|
+
for (const c of candidates) {
|
|
921
|
+
if (!existsSync(c)) continue;
|
|
922
|
+
try {
|
|
923
|
+
const mod = await import(pathToFileURL(c).href);
|
|
924
|
+
if (typeof mod.recallHybrid === 'function' && typeof mod.patternRecordId === 'function') return mod;
|
|
925
|
+
} catch {
|
|
926
|
+
/* try the next candidate */
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return undefined;
|
|
930
|
+
})();
|
|
931
|
+
return coreModulePromise;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// FR-2 (hook-recall-hybrid-parity): the hybrid leg is time-boxed so a slow/cold engine can never make ONE prompt pay the full
|
|
935
|
+
// cost — it falls back to the warm cosine below instead. 500 ms leaves the hook's own 800 ms
|
|
936
|
+
// timeout (recallHookSource's TIMEOUT_MS) headroom for the socket round-trip itself.
|
|
937
|
+
// Measured 2026-09-14 (nfr1-measure.mjs, real 743-pattern store, warm-up on, n=100 x2): hybrid p50 60-65 ms,
|
|
938
|
+
// p95 100-180 ms, max 374 ms; 500 ms ≈ 3x p95 and stays under the hook's own 800 ms client timeout.
|
|
939
|
+
const HOOK_RECALL_BUDGET_MS = Number(process.env['HOOK_RECALL_BUDGET_MS'] || 500);
|
|
940
|
+
// Codex round-2 (2026-09-14): a hybrid attempt that LOST the race keeps running in the background —
|
|
941
|
+
// this cap keeps a burst of slow requests from stacking unbounded engine work; past it, requests answer
|
|
942
|
+
// with cosine at once, honestly labelled. Real cancellation needs worker isolation (backlog 14c1316b).
|
|
943
|
+
const HYBRID_MAX_IN_FLIGHT = (() => {
|
|
944
|
+
const raw = Number(process.env['HOOK_HYBRID_MAX_IN_FLIGHT'] || 2);
|
|
945
|
+
// Codex round-3: NaN/Infinity/0/negative must not silently disable the cap — fall back to 2.
|
|
946
|
+
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 2;
|
|
947
|
+
})();
|
|
948
|
+
let hybridInFlight = 0;
|
|
949
|
+
// Test-only fault injection (AC-2): a positive value delays the hybrid leg so the budget can be
|
|
950
|
+
// PROVEN to fire without a real slow engine. Absent/0 in every real deployment.
|
|
951
|
+
const DZ_EMBED_HYBRID_DELAY_MS = Number(process.env['DZ_EMBED_HYBRID_DELAY_MS'] || 0);
|
|
952
|
+
// AM-5 (fix round 1): the daemon used to re-normalize recallHybrid's raw RRF score into [0,1] with
|
|
953
|
+
// its OWN copy of vector-tier.ts's RRF_K constant — two numbers that could silently drift apart
|
|
954
|
+
// (this file is standalone generated text and cannot \`import\` the compiled core constant), AND a
|
|
955
|
+
// scale \`dz recall --json\`'s own \`relevance\` field (cli.ts: \`relevance: … h.score …\`) never
|
|
956
|
+
// applies — so the hook's floor and the CLI's floor were never comparable numbers even though both
|
|
957
|
+
// ultimately came from the same recallHybrid() call. Fixed: the daemon now reports \`h.score\`
|
|
958
|
+
// UNCHANGED — the exact raw core score \`dz recall --json\` already reports as \`relevance\` — so a
|
|
959
|
+
// floor calibrated against one is valid against the other (AM-4's parity test asserts the two are
|
|
960
|
+
// literally equal, not merely proportional).
|
|
961
|
+
|
|
618
962
|
// embed-socket-short-path (FR-1): a unix socket path has a hard platform limit on \`sun_path\`
|
|
619
963
|
// (Linux 108 bytes incl. NUL, macOS 104) — a deeply nested project's \`.dz/embed.sock\` can exceed
|
|
620
964
|
// it, and \`listen()\` then fails while every OTHER part of the daemon looks healthy. This mirrors
|
|
@@ -733,6 +1077,27 @@ function quarantinedOf(metadataJson) {
|
|
|
733
1077
|
}
|
|
734
1078
|
}
|
|
735
1079
|
|
|
1080
|
+
// AM-3 (fix round 1, apply-leg-never-silent): the vector mirror's own row carries no domain column
|
|
1081
|
+
// (see loadPatterns' own note below) — domain lives in the SAME metadata JSON dzIdOf/quarantinedOf
|
|
1082
|
+
// already parse, so this is the ONE extra field read off a column that's already in hand.
|
|
1083
|
+
function domainOf(metadataJson) {
|
|
1084
|
+
try {
|
|
1085
|
+
const d = JSON.parse(String(metadataJson || '{}'))?.domain;
|
|
1086
|
+
return typeof d === 'string' && d !== '' ? d : undefined;
|
|
1087
|
+
} catch {
|
|
1088
|
+
return undefined;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// AM-3: the ONE domain a genuine session must never see — probeApplyLeg's own beacon marker
|
|
1093
|
+
// (apply-leg.ts's PROBE_BEACON_DOMAIN, inlined here as text for the same reason every other shared
|
|
1094
|
+
// constant in this generated file is: a template string cannot import a compiled module).
|
|
1095
|
+
const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
|
|
1096
|
+
/** Strip probe-domain hits from a real (non-probe) answer; a probe request passes through untouched. */
|
|
1097
|
+
function filterProbeHits(hits, probe) {
|
|
1098
|
+
return probe ? hits : hits.filter((h) => h.domain !== PROBE_BEACON_DOMAIN);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
736
1101
|
async function main() {
|
|
737
1102
|
if (await socketAlive(SOCKET)) {
|
|
738
1103
|
log('a daemon already owns', SOCKET, '— exiting');
|
|
@@ -782,7 +1147,9 @@ async function main() {
|
|
|
782
1147
|
return rows.map((r) => {
|
|
783
1148
|
const buf = r.embedding;
|
|
784
1149
|
const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
785
|
-
|
|
1150
|
+
// AM-3: domain rides along so answerRecall can exclude the probe's own beacon from a real
|
|
1151
|
+
// session's cosine-fallback answer — the SAME metadata column dzIdOf/quarantinedOf already read.
|
|
1152
|
+
return { dzId: dzIdOf(r.metadata), pattern: r.pattern, vec, quarantined: quarantinedOf(r.metadata), domain: domainOf(r.metadata) };
|
|
786
1153
|
});
|
|
787
1154
|
} finally {
|
|
788
1155
|
db.close();
|
|
@@ -792,6 +1159,120 @@ async function main() {
|
|
|
792
1159
|
let patterns = loadPatterns();
|
|
793
1160
|
let patternsAt = Date.now();
|
|
794
1161
|
|
|
1162
|
+
// ADR-001 (hook-recall-hybrid-parity, D1): try core's recallHybrid FIRST, budget-bounded.
|
|
1163
|
+
// AM-2 (fix round 1, wall clock from request receipt): the budget timer is armed BEFORE
|
|
1164
|
+
// \`loadCoreModule()\` runs, not after it resolves — the FIRST call's dynamic \`import()\` cost used
|
|
1165
|
+
// to be spent OUTSIDE the race, so a slow/cold module resolution could add its own latency on top
|
|
1166
|
+
// of the full \`HOOK_RECALL_BUDGET_MS\` window instead of eating into it.
|
|
1167
|
+
// NAMED LIMIT (AM-2): \`Promise.race\` cannot PREEMPT synchronous work — if \`core.recallHybrid\`
|
|
1168
|
+
// (or anything it calls) blocks the event loop synchronously, this race does not return until
|
|
1169
|
+
// that work finishes, budget or not; Node has no cooperative-preemption primitive for that. The
|
|
1170
|
+
// budget only bounds work that yields the event loop somewhere (every real I/O/await in
|
|
1171
|
+
// recallHybrid does). The hook's OWN client-side \`TIMEOUT_MS\` (recallHookSource, 800 ms) is the
|
|
1172
|
+
// actual backstop against a synchronously-blocked daemon: it times out the SOCKET, not the
|
|
1173
|
+
// daemon's internal race, so the hook always returns promptly even if this promise never does.
|
|
1174
|
+
//
|
|
1175
|
+
// \`hybridRecall\`'s own promise is left running past a timeout loss (never awaited a second time)
|
|
1176
|
+
// — its \`.catch\` below only silences a LATE rejection so a slow, eventually-failing engine call
|
|
1177
|
+
// can never become an unhandled-rejection crash for this long-lived process.
|
|
1178
|
+
async function hybridRecall(prompt, limit, probe) {
|
|
1179
|
+
if (hybridInFlight >= HYBRID_MAX_IN_FLIGHT) {
|
|
1180
|
+
return { ok: false, reason: \`hybrid saturated (\${hybridInFlight} attempt(s) still in flight, cap \${HYBRID_MAX_IN_FLIGHT})\` };
|
|
1181
|
+
}
|
|
1182
|
+
const TIMED_OUT = Symbol('timed-out');
|
|
1183
|
+
let timer;
|
|
1184
|
+
const budget = new Promise((resolve) => {
|
|
1185
|
+
timer = setTimeout(() => resolve(TIMED_OUT), HOOK_RECALL_BUDGET_MS);
|
|
1186
|
+
timer.unref?.();
|
|
1187
|
+
});
|
|
1188
|
+
hybridInFlight += 1;
|
|
1189
|
+
const attempt = (async () => {
|
|
1190
|
+
const core = await loadCoreModule();
|
|
1191
|
+
if (core === undefined) return { unavailable: true };
|
|
1192
|
+
if (DZ_EMBED_HYBRID_DELAY_MS > 0) await new Promise((r) => setTimeout(r, DZ_EMBED_HYBRID_DELAY_MS));
|
|
1193
|
+
const result = await core.recallHybrid(PROJECT, prompt, { limit, mode: 'hook', deferExposures: true });
|
|
1194
|
+
return { unavailable: false, result, core };
|
|
1195
|
+
})();
|
|
1196
|
+
// the in-flight count follows the UNDERLYING attempt, not the race: a timed-out attempt still
|
|
1197
|
+
// occupies its slot until it settles (that is the whole point of the cap)
|
|
1198
|
+
attempt.then(() => { hybridInFlight -= 1; }, () => { hybridInFlight -= 1; });
|
|
1199
|
+
attempt.catch(() => {}); // swallow a rejection that arrives AFTER the budget already won the race
|
|
1200
|
+
try {
|
|
1201
|
+
const raced = await Promise.race([attempt, budget]);
|
|
1202
|
+
clearTimeout(timer);
|
|
1203
|
+
if (raced === TIMED_OUT) return { ok: false, reason: \`budget exceeded (\${HOOK_RECALL_BUDGET_MS} ms)\` };
|
|
1204
|
+
if (raced.unavailable) return { ok: false, reason: 'core module unavailable' };
|
|
1205
|
+
// AM-3 (fix round 1): everything past the race — reading result.hits, a hit missing its
|
|
1206
|
+
// required fields, patternRecordId() throwing on a malformed pattern — is now INSIDE this
|
|
1207
|
+
// try, so any such failure falls back to cosine with an honest \`reason\` instead of reaching
|
|
1208
|
+
// the socket handler's outer catch, which used to turn it into a bare protocol {error} reply
|
|
1209
|
+
// (never engine:'cosine-fallback') — the exact defect this amendment fixes.
|
|
1210
|
+
const { result, core } = raced;
|
|
1211
|
+
const hits = (result.hits || []).map((h) => ({
|
|
1212
|
+
dzId: core.patternRecordId(h.pattern),
|
|
1213
|
+
pattern: h.pattern.pattern,
|
|
1214
|
+
score: h.score, // AM-5: raw core score, unchanged — the same number \`dz recall --json\` reports as \`relevance\`
|
|
1215
|
+
domain: h.pattern.domain,
|
|
1216
|
+
...(h.quarantined ? { quarantined: true } : {}),
|
|
1217
|
+
}));
|
|
1218
|
+
// AM-3 (fix round 1): filter BEFORE slicing to \`limit\` — filtering after would let a probe
|
|
1219
|
+
// beacon that happened to rank in the top \`limit\` silently crowd out a real hit for a real
|
|
1220
|
+
// (non-probe) caller instead of simply being excluded from consideration.
|
|
1221
|
+
return { ok: true, hits: filterProbeHits(hits, probe).slice(0, limit) };
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
clearTimeout(timer);
|
|
1224
|
+
return { ok: false, reason: \`recallHybrid failed: \${err?.message ?? err}\` };
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** \`op: recall\`'s whole answer: hybrid first (budget-bounded), cosine fallback on ANY failure —
|
|
1229
|
+
* always honestly labelled with \`engine\`/\`reason\` (FR-2/FR-6). */
|
|
1230
|
+
async function answerRecall(prompt, limitRaw, probe) {
|
|
1231
|
+
const limit = Math.min(Number(limitRaw) || 8, 32);
|
|
1232
|
+
if (prompt.trim() === '') return { hits: [], engine: 'none', reason: 'empty prompt' }; // Codex round-2: every reply carries \`engine\`
|
|
1233
|
+
const hybrid = await hybridRecall(prompt, limit, probe);
|
|
1234
|
+
if (hybrid.ok) return { hits: hybrid.hits, engine: 'hybrid' };
|
|
1235
|
+
// Reload the cosine mirror if it changed on disk (a \`dz teach\` between turns) — the SAME
|
|
1236
|
+
// staleness window as before this feature, just checked only when actually falling back.
|
|
1237
|
+
if (Date.now() - patternsAt > 5000) {
|
|
1238
|
+
try {
|
|
1239
|
+
patterns = loadPatterns();
|
|
1240
|
+
} catch {
|
|
1241
|
+
/* keep the previous snapshot */
|
|
1242
|
+
}
|
|
1243
|
+
patternsAt = Date.now();
|
|
1244
|
+
}
|
|
1245
|
+
if (patterns.length === 0) return { hits: [], engine: 'cosine-fallback', reason: hybrid.reason };
|
|
1246
|
+
const qv = await embed(prompt);
|
|
1247
|
+
const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), domain: p.domain, ...(p.quarantined ? { quarantined: true } : {}) }));
|
|
1248
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1249
|
+
// AM-3: same domain exclusion as the hybrid leg, applied before slicing for the same reason.
|
|
1250
|
+
return { hits: filterProbeHits(scored, probe).slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
|
|
1254
|
+
// ~2-3.6 s, warm ~1 ms, MEASURED, see the manifest's T8/AM-1 discussion) — OFF the request path,
|
|
1255
|
+
// so the first REAL \`op: recall\` is not the one that pays the cold init. Fired fire-and-forget
|
|
1256
|
+
// right before \`listen()\` below, never awaited by startup: this is a best-effort head start, not
|
|
1257
|
+
// a guarantee — a request landing in the few-hundred-ms window before it completes still pays the
|
|
1258
|
+
// cold cost exactly as before this amendment, and a warm-up failure (no core module, engine
|
|
1259
|
+
// error) is silently swallowed — never-block applies to startup exactly as it does to a request.
|
|
1260
|
+
// Measured: the slowest cold resolveAgentdbEmbedder init observed in this environment was 3653 ms
|
|
1261
|
+
// (T8 log, 2026-09-14) — 10 s leaves a wide margin without risking an unbounded warm-up hang.
|
|
1262
|
+
const WARMUP_TIMEOUT_MS = 10_000;
|
|
1263
|
+
async function warmUpHybridEngine() {
|
|
1264
|
+
const core = await loadCoreModule();
|
|
1265
|
+
if (core === undefined) return;
|
|
1266
|
+
const guard = new Promise((resolve) => {
|
|
1267
|
+
const t = setTimeout(resolve, WARMUP_TIMEOUT_MS);
|
|
1268
|
+
t.unref?.();
|
|
1269
|
+
});
|
|
1270
|
+
// An empty-string query still exercises the FULL semantic leg (embed + engine.search), which is
|
|
1271
|
+
// exactly what needs warming; recallHybrid degrades any error inside it honestly, so nothing
|
|
1272
|
+
// here needs its own try/catch beyond the outer .catch(() => {}) at the call site below.
|
|
1273
|
+
await Promise.race([core.recallHybrid(PROJECT, '', { limit: 1, mode: 'hook', deferExposures: true }), guard]);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
795
1276
|
let idleTimer;
|
|
796
1277
|
let lastActivityAt = Date.now();
|
|
797
1278
|
const touch = () => {
|
|
@@ -826,24 +1307,8 @@ async function main() {
|
|
|
826
1307
|
sock.write(JSON.stringify({ ok: true }) + '\\n');
|
|
827
1308
|
return shutdown(0);
|
|
828
1309
|
} else if (msg.op === 'recall') {
|
|
829
|
-
// Reload the mirror if it changed on disk (a \`dz teach\` between turns).
|
|
830
|
-
if (Date.now() - patternsAt > 5000) {
|
|
831
|
-
try {
|
|
832
|
-
patterns = loadPatterns();
|
|
833
|
-
} catch {
|
|
834
|
-
/* keep the previous snapshot */
|
|
835
|
-
}
|
|
836
|
-
patternsAt = Date.now();
|
|
837
|
-
}
|
|
838
1310
|
const prompt = typeof msg.prompt === 'string' ? msg.prompt : '';
|
|
839
|
-
|
|
840
|
-
reply = { hits: [] };
|
|
841
|
-
} else {
|
|
842
|
-
const qv = await embed(prompt);
|
|
843
|
-
const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), ...(p.quarantined ? { quarantined: true } : {}) }));
|
|
844
|
-
scored.sort((a, b) => b.score - a.score);
|
|
845
|
-
reply = { hits: scored.slice(0, Math.min(Number(msg.limit) || 8, 32)) };
|
|
846
|
-
}
|
|
1311
|
+
reply = await answerRecall(prompt, msg.limit, msg.probe === true);
|
|
847
1312
|
} else {
|
|
848
1313
|
reply = { error: \`unknown op \${String(msg.op)}\` };
|
|
849
1314
|
}
|
|
@@ -882,6 +1347,9 @@ async function main() {
|
|
|
882
1347
|
|
|
883
1348
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => shutdown(0));
|
|
884
1349
|
|
|
1350
|
+
// AM-1: fire-and-forget, never awaited — bind proceeds immediately regardless of warm-up outcome.
|
|
1351
|
+
warmUpHybridEngine().catch(() => {});
|
|
1352
|
+
|
|
885
1353
|
// FR-3 ("absence of a receipt is not success"): \`ready\` is printed ONLY after \`listen\`'s callback
|
|
886
1354
|
// AND a fresh \`existsSync(SOCKET)\` both confirm the socket file is actually on disk — a caller
|
|
887
1355
|
// that greps stderr for "ready" must never see it for a socket that silently failed to bind.
|
|
@@ -935,25 +1403,101 @@ main().catch((err) => {
|
|
|
935
1403
|
});
|
|
936
1404
|
`;
|
|
937
1405
|
}
|
|
1406
|
+
/**
|
|
1407
|
+
* POSIX single-quote a value for safe interpolation into a shell command line: wraps it in `'`,
|
|
1408
|
+
* escaping every embedded `'` as the standard `'\''` sequence (close quote, literal escaped quote,
|
|
1409
|
+
* reopen quote). Single quotes disable EVERY shell expansion — `$`, backticks, `"`, another `'` —
|
|
1410
|
+
* unlike a bare `"..."` interpolation, which blocks only whitespace/globbing and still lets `$`/
|
|
1411
|
+
* backtick content run (AM-1, fix round 1, HIGH).
|
|
1412
|
+
*/
|
|
1413
|
+
function shellQuote(value) {
|
|
1414
|
+
return `'${value.split(`'`).join(`'\\''`)}'`;
|
|
1415
|
+
}
|
|
938
1416
|
/**
|
|
939
1417
|
* The two hook-registry entries `runSetup` merges into `.claude/settings.json` (FR-1). Commands
|
|
940
1418
|
* match the hub's own `.claude/settings.json` verbatim (`grep`-diffed against it at authoring time):
|
|
941
1419
|
* the recall hook is invoked with a swallowed non-zero exit (`|| true`) so a broken hook body never
|
|
942
1420
|
* fails a prompt, and the embed daemon is spawned detached via `nohup` + a backgrounding `sh -c`
|
|
943
1421
|
* so `SessionStart` never waits on model load.
|
|
1422
|
+
*
|
|
1423
|
+
* `installRoot` (ADR-001 D2, feature `apply-leg-install-root`): when the caller (`dz setup`) knows
|
|
1424
|
+
* its own install root, the commands bake it in as an ABSOLUTE path — the deployed helper already
|
|
1425
|
+
* bakes an absolute `CORE_DIST_DIR`, so a `${CLAUDE_PROJECT_DIR:-.}`-relative command in
|
|
1426
|
+
* settings.json only masked that non-portability, and broke down to `Cannot find module` (swallowed
|
|
1427
|
+
* by `2>/dev/null || true`) whenever `project === $HOME` and a session's own `CLAUDE_PROJECT_DIR`
|
|
1428
|
+
* pointed elsewhere (issue #2). Omitting `installRoot` (every pre-existing zero-arg caller — status
|
|
1429
|
+
* fixtures, `applyLegStatus` regression tests) keeps the original `CLAUDE_PROJECT_DIR`-relative
|
|
1430
|
+
* form byte for byte; `hookCommandInvokes`/`applyLegStatus` (FR-3) recognize BOTH forms as wired,
|
|
1431
|
+
* and `runSetup`'s `addIfMissing` (setup.ts) REPLACES a stale form with the current one in place —
|
|
1432
|
+
* never a second entry — on re-setup.
|
|
1433
|
+
*
|
|
1434
|
+
* Fix round 1 corrections to the absolute (`installRoot`-given) branch — the legacy zero-arg branch
|
|
1435
|
+
* is UNCHANGED byte for byte:
|
|
1436
|
+
* - AM-1 (HIGH): a caller-controlled path was interpolated RAW into shell source. A `"`, `$`,
|
|
1437
|
+
* backtick, or `'` in `installRoot` altered or injected commands, and the SessionStart form broke
|
|
1438
|
+
* outright on a `'` (it cannot be escaped inside a `'...'` body by nesting `"`). Fixed:
|
|
1439
|
+
* {@link shellQuote} wraps every path; SessionStart passes them as POSITIONAL ARGS (`$1`/`$2`) to
|
|
1440
|
+
* an INNER `sh -c` whose script text is a FIXED literal with no caller-controlled bytes, so
|
|
1441
|
+
* nested-quote fragility cannot arise at all.
|
|
1442
|
+
* - AM-3 (HIGH): the daemon used to fall back to `DZ_PROJECT_ROOT ?? installLocal`, and nothing in
|
|
1443
|
+
* the SessionStart command ever SET that variable — a stale inherited `DZ_PROJECT_ROOT` in the
|
|
1444
|
+
* parent env could win over the install root the hook itself resolves to. Fixed: the SessionStart
|
|
1445
|
+
* command now sets `DZ_PROJECT_ROOT="$1"` (`$1` = installRoot) explicitly, so the daemon and the
|
|
1446
|
+
* hook agree by construction regardless of what the parent environment happens to carry.
|
|
1447
|
+
* - AM-4 (LOW): a relative `installRoot` used to produce a relative command, breaking the "every
|
|
1448
|
+
* baked path is absolute" invariant the module's own docs claim. Fixed: `resolve()`s its input.
|
|
944
1449
|
*/
|
|
945
|
-
export function applyLegHookEntries() {
|
|
1450
|
+
export function applyLegHookEntries(installRoot) {
|
|
1451
|
+
if (installRoot === undefined) {
|
|
1452
|
+
// Legacy zero-arg form — byte-identical to every pre-fix-round build. Kept only for
|
|
1453
|
+
// `applyLegStatus`'s upgrade-recognition tests (a pre-feature install's settings.json) and for
|
|
1454
|
+
// seeding "stale entry" fixtures; every real `dz setup` caller passes `opts.projectRoot`.
|
|
1455
|
+
return {
|
|
1456
|
+
userPromptSubmit: {
|
|
1457
|
+
hooks: [{
|
|
1458
|
+
type: 'command',
|
|
1459
|
+
// FR-2 (apply-leg-never-silent, ADR-001 D2): `2>/dev/null` removed — the hook itself now
|
|
1460
|
+
// names every silent exit on stderr (FR-1), and swallowing that stream at the settings.json
|
|
1461
|
+
// level would defeat it at the source. `|| true` stays: a broken hook body must never fail
|
|
1462
|
+
// the prompt.
|
|
1463
|
+
command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/recall-hook.cjs" || true',
|
|
1464
|
+
}],
|
|
1465
|
+
},
|
|
1466
|
+
sessionStart: {
|
|
1467
|
+
hooks: [{
|
|
1468
|
+
type: 'command',
|
|
1469
|
+
command: "sh -c 'nohup node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/dz-embed-daemon.mjs\" >/dev/null 2>&1 & exit 0'",
|
|
1470
|
+
}],
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
// AM-4: make a relative caller input absolute so the "every baked path is absolute" invariant
|
|
1475
|
+
// holds regardless of what the caller passed, not merely for callers that already resolve first.
|
|
1476
|
+
const root = resolve(installRoot);
|
|
1477
|
+
const recallHookPath = `${root}/.claude/helpers/recall-hook.cjs`;
|
|
1478
|
+
const daemonPath = `${root}/.claude/helpers/dz-embed-daemon.mjs`;
|
|
946
1479
|
return {
|
|
947
1480
|
userPromptSubmit: {
|
|
948
1481
|
hooks: [{
|
|
949
1482
|
type: 'command',
|
|
950
|
-
|
|
1483
|
+
// AM-1: shellQuote the WHOLE path — a bare `"..."` interpolation only blocks whitespace and
|
|
1484
|
+
// globbing, it still lets `$`, backticks, and a literal `"` do damage; single-quoting blocks
|
|
1485
|
+
// every shell expansion at once.
|
|
1486
|
+
// FR-2 (apply-leg-never-silent, ADR-001 D2): `2>/dev/null` removed — see the zero-arg branch's
|
|
1487
|
+
// comment above for why.
|
|
1488
|
+
command: `node ${shellQuote(recallHookPath)} || true`,
|
|
951
1489
|
}],
|
|
952
1490
|
},
|
|
953
1491
|
sessionStart: {
|
|
954
1492
|
hooks: [{
|
|
955
1493
|
type: 'command',
|
|
956
|
-
|
|
1494
|
+
// AM-1/AM-3: the INNER script text (`'DZ_PROJECT_ROOT="$1" nohup node "$2" …'`) is a FIXED
|
|
1495
|
+
// literal — no caller-controlled byte ever sits inside it, so it can never itself contain an
|
|
1496
|
+
// unescaped `'` that would break the outer single-quoting. `root`/`daemonPath` instead arrive
|
|
1497
|
+
// as POSITIONAL ARGS (`$1`/`$2`), each independently shellQuote()d for the OUTER shell that
|
|
1498
|
+
// parses this whole command line. AM-3: `DZ_PROJECT_ROOT="$1"` pins the daemon to THIS
|
|
1499
|
+
// install root explicitly — a stale value already in the parent environment can never win.
|
|
1500
|
+
command: `sh -c 'DZ_PROJECT_ROOT="$1" nohup node "$2" >/dev/null 2>&1 & exit 0' sh ${shellQuote(root)} ${shellQuote(daemonPath)}`,
|
|
957
1501
|
}],
|
|
958
1502
|
},
|
|
959
1503
|
};
|
|
@@ -979,10 +1523,29 @@ function readHelperStatus(path) {
|
|
|
979
1523
|
* Codex, third pass). A bare mention (`echo .claude/helpers/recall-hook.cjs`) is not an invocation:
|
|
980
1524
|
* the helper path must follow a `node` word — directly, or inside the daemon's
|
|
981
1525
|
* `sh -c 'nohup node "…"'` spawn. Forward slashes only: every command dz writes uses them.
|
|
1526
|
+
*
|
|
1527
|
+
* Fix round 1 (AM-1/AM-3, apply-leg-install-root): the SessionStart command now passes its daemon
|
|
1528
|
+
* path as a POSITIONAL ARG (`sh -c '… node "$2" …' sh <root> <daemonPath>`) rather than interpolating
|
|
1529
|
+
* it textually next to `node`, so the ORIGINAL adjacency regex alone no longer matches it. A SECOND
|
|
1530
|
+
* recognizer accepts that shape: the command invokes `node` with a `$N`-style positional argument
|
|
1531
|
+
* AND carries `markerPath` as one of its own (shellQuote()d) trailing arguments — both conditions
|
|
1532
|
+
* together, so a foreign command that merely echoes the marker path near an unrelated `node "$1"`
|
|
1533
|
+
* invocation still does not count.
|
|
982
1534
|
*/
|
|
983
1535
|
export function hookCommandInvokes(command, markerPath) {
|
|
984
|
-
const
|
|
985
|
-
|
|
1536
|
+
const escapedMarker = markerPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
1537
|
+
const direct = new RegExp('(^|[\\s;&|(])node\\s+[\'"]?[^\\s\'"]*' + escapedMarker);
|
|
1538
|
+
if (direct.test(command))
|
|
1539
|
+
return true;
|
|
1540
|
+
// Codex round-2 (NEW): the absolute form is POSIX-single-quoted (`node '<root>/.claude/…'`), and a
|
|
1541
|
+
// root may contain whitespace or `'` (written as `'\''`) — the bare `[^\s'"]*` run above stops at
|
|
1542
|
+
// the first space, so such an entry read as "absent" and re-setup appended a duplicate.
|
|
1543
|
+
const quotedDirect = new RegExp("(^|[\\s;&|(])node\\s+'(?:[^']|'\\\\'')*" + escapedMarker);
|
|
1544
|
+
if (quotedDirect.test(command))
|
|
1545
|
+
return true;
|
|
1546
|
+
const invokesNodeWithPositional = /\bnode\s+["']?\$\d/.test(command);
|
|
1547
|
+
const markerAsQuotedArg = new RegExp("'[^']*" + escapedMarker + "'");
|
|
1548
|
+
return invokesNodeWithPositional && markerAsQuotedArg.test(command);
|
|
986
1549
|
}
|
|
987
1550
|
/**
|
|
988
1551
|
* A hook-registry entry naming `markerPath` is wired under `event` in the PARSED settings structure
|
|
@@ -1085,4 +1648,253 @@ export function applyLegReasonMessage(status) {
|
|
|
1085
1648
|
}
|
|
1086
1649
|
return 'not installed — run dz setup --target claude-code --memory agentdb';
|
|
1087
1650
|
}
|
|
1651
|
+
/**
|
|
1652
|
+
* The ACTUAL command `.claude/settings.json` carries for the wired `UserPromptSubmit` recall hook —
|
|
1653
|
+
* not a reconstruction. {@link probeApplyLeg} must run exactly what a real session would run,
|
|
1654
|
+
* `${CLAUDE_PROJECT_DIR:-.}`-relative legacy form and all: reconstructing our own `node <path> ||
|
|
1655
|
+
* true` would silently stop testing the shell-expansion half of the legacy form, the exact half
|
|
1656
|
+
* issue #2 broke. Mirrors {@link hookWiredUnder}'s traversal (kept in lock-step: both read
|
|
1657
|
+
* `hooks.UserPromptSubmit[*].hooks[*].command` and recognize it via {@link hookCommandInvokes}) but
|
|
1658
|
+
* returns the command TEXT instead of a boolean.
|
|
1659
|
+
*/
|
|
1660
|
+
function findConfiguredRecallHookCommand(root) {
|
|
1661
|
+
try {
|
|
1662
|
+
const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8'));
|
|
1663
|
+
const hooksSection = settings?.hooks;
|
|
1664
|
+
const list = hooksSection && typeof hooksSection === 'object' ? hooksSection['UserPromptSubmit'] : undefined;
|
|
1665
|
+
if (!Array.isArray(list))
|
|
1666
|
+
return undefined;
|
|
1667
|
+
for (const entry of list) {
|
|
1668
|
+
for (const cmd of hookCommandsOf(entry)) {
|
|
1669
|
+
if (hookCommandInvokes(cmd, '.claude/helpers/recall-hook.cjs'))
|
|
1670
|
+
return cmd;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
catch {
|
|
1675
|
+
/* settings.json absent, unreadable, or not valid JSON — nothing to probe */
|
|
1676
|
+
}
|
|
1677
|
+
return undefined;
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* AM-4 (fix round 1, apply-leg-never-silent): the LEGACY zero-arg form ({@link applyLegHookEntries}'s
|
|
1681
|
+
* no-installRoot branch) reads `${CLAUDE_PROJECT_DIR:-.}` — a shell expansion that only resolves to
|
|
1682
|
+
* something useful from a REAL session's own cwd. Spawning it from `probeApplyLeg`'s temp "foreign"
|
|
1683
|
+
* cwd can never find the deployed helper by construction (the file lives at `root`'s own
|
|
1684
|
+
* `.claude/helpers/`, never under the temp dir), so a probe against this form would spawn a doomed
|
|
1685
|
+
* command and report a confusing generic failure — not a fact about whether the leg injects, only a
|
|
1686
|
+
* fact about the fixture being unprobeable. The absolute form ({@link shellQuote}'d installRoot)
|
|
1687
|
+
* never contains this literal env-expansion syntax — it bakes a resolved path instead — so a plain
|
|
1688
|
+
* substring check distinguishes the two without re-parsing shell grammar.
|
|
1689
|
+
*/
|
|
1690
|
+
export function isLegacyRelativeRecallCommand(command) {
|
|
1691
|
+
return command.includes('${CLAUDE_PROJECT_DIR');
|
|
1692
|
+
}
|
|
1693
|
+
/** Words a real prompt needs to clear `hasEnoughSignal` (recall-hook-policy.ts: `MIN_PROMPT_CHARS`
|
|
1694
|
+
* 10, `MIN_CONTENT_TOKENS` 2) — a bare unique token alone is ONE token and would be silently
|
|
1695
|
+
* dropped by the very floor this probe means to exercise honestly. */
|
|
1696
|
+
const PROBE_PROMPT_WORDS = 'apply leg live probe';
|
|
1697
|
+
/** Doctor/parity probes share ONE domain tag so a leaked beacon (a failed removal) is trivially
|
|
1698
|
+
* findable and excludable — never `dz-teach`/`general`, which would blend it into real lessons. */
|
|
1699
|
+
const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
|
|
1700
|
+
/**
|
|
1701
|
+
* Live, end-to-end proof that the apply leg actually injects — ADR-001 Decision 1. `applyLegStatus`
|
|
1702
|
+
* only proves FILES exist and are STRUCTURALLY wired (issue #2's whole defect: four green checks,
|
|
1703
|
+
* a leg that injected nothing in every session but one). This spawns the REAL configured hook
|
|
1704
|
+
* command from a TEMPORARY cwd with `CLAUDE_PROJECT_DIR` pointing at that same temp dir — the exact
|
|
1705
|
+
* shape of a real Claude Code session, which never runs a hook from the project root itself — and
|
|
1706
|
+
* asks it to recall a throwaway "beacon" lesson written into the store for the duration of the call.
|
|
1707
|
+
* `ok: true` only when the beacon's own SECRET token (fix round 1, AM-1 — never sent as input, only
|
|
1708
|
+
* stored) comes back inside `additionalContext`; every other outcome is `ok: false` with a `reason`,
|
|
1709
|
+
* never a silent guess.
|
|
1710
|
+
*
|
|
1711
|
+
* The beacon is written via {@link recordPattern} (the SAME lexical-store seam `dz teach` uses) and
|
|
1712
|
+
* removed via {@link removePatternsByIds} in a `finally` — a probe that throws, times out, or never
|
|
1713
|
+
* finds the leg alive still leaves the store exactly as it found it (proven by a count-before ==
|
|
1714
|
+
* count-after test, not merely claimed).
|
|
1715
|
+
*
|
|
1716
|
+
* `timeoutMs` bounds `probeHookLiveness`'s spawn. Measured (this environment, 2026-09-14, T1): a
|
|
1717
|
+
* `store-not-found`/`socket-absent` early exit returns in well under 200 ms; a live-daemon probe
|
|
1718
|
+
* answers in ~100-200 ms, matching ADR-001's own estimate. 8000 ms leaves roughly a 40x margin for a
|
|
1719
|
+
* loaded daemon without ever approaching `probeHookLiveness`'s own un-overridden 20 000 ms ceiling —
|
|
1720
|
+
* a genuinely dead probe still returns to `dz doctor`/`dz parity` in bounded time.
|
|
1721
|
+
*
|
|
1722
|
+
* `env` is a TEST-ONLY escape hatch (never used by `dz doctor`/`dz parity`, both call this with
|
|
1723
|
+
* default opts): it lets a test widen the HOOK's OWN internal socket-connect timeout
|
|
1724
|
+
* (`DZ_RECALL_HOOK_TIMEOUT_MS`) against a genuinely cold daemon, matching the same widening
|
|
1725
|
+
* `apply-leg-recall-parity.test.ts`/`apply-leg-install-root.test.ts` already apply to the daemon's
|
|
1726
|
+
* `HOOK_RECALL_BUDGET_MS`. Merged BEFORE `CLAUDE_PROJECT_DIR`, so a caller can never override the
|
|
1727
|
+
* one env var this probe's own honesty depends on.
|
|
1728
|
+
*/
|
|
1729
|
+
export async function probeApplyLeg(root, opts = {}) {
|
|
1730
|
+
const started = Date.now();
|
|
1731
|
+
const elapsed = () => Date.now() - started;
|
|
1732
|
+
const status = applyLegStatus(root);
|
|
1733
|
+
if (!status.installed) {
|
|
1734
|
+
return { ok: false, reason: 'apply-leg not installed', elapsedMs: elapsed() };
|
|
1735
|
+
}
|
|
1736
|
+
const command = findConfiguredRecallHookCommand(root);
|
|
1737
|
+
if (command === undefined) {
|
|
1738
|
+
return { ok: false, reason: 'no UserPromptSubmit entry invokes recall-hook.cjs (settings.json missing or unreadable)', elapsedMs: elapsed() };
|
|
1739
|
+
}
|
|
1740
|
+
// AM-4: the legacy relative form can never be reached from a foreign cwd by construction — see
|
|
1741
|
+
// isLegacyRelativeRecallCommand's own doc comment. Reported BEFORE any beacon is written: there is
|
|
1742
|
+
// nothing to clean up for a probe that never ran.
|
|
1743
|
+
if (isLegacyRelativeRecallCommand(command)) {
|
|
1744
|
+
return { ok: false, reason: 'legacy-relative-command', elapsedMs: elapsed() };
|
|
1745
|
+
}
|
|
1746
|
+
// AM-1 (CRITICAL, fix round 1): two INDEPENDENT tokens, not one. `queryToken` rides the PROMPT the
|
|
1747
|
+
// probe sends the hook — a dead/stub hook that merely echoes its own stdin back into
|
|
1748
|
+
// `additionalContext` makes THIS token reappear too, so it alone can never prove genuine
|
|
1749
|
+
// injection. `secretToken` exists ONLY inside the beacon's STORED pattern text and is never sent
|
|
1750
|
+
// to the hook as input — only a hook that actually queried the store and returned a matched
|
|
1751
|
+
// pattern's own text can produce it. `ok: true` therefore requires the SECRET, never the query.
|
|
1752
|
+
const queryToken = `dzapplylegquery${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
1753
|
+
const secretToken = `dzapplylegsecret${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
1754
|
+
const beaconPattern = {
|
|
1755
|
+
pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken}`,
|
|
1756
|
+
type: 'lesson-learned',
|
|
1757
|
+
reward: 0,
|
|
1758
|
+
domain: PROBE_BEACON_DOMAIN,
|
|
1759
|
+
ts: new Date().toISOString(),
|
|
1760
|
+
source: 'apply-leg-probe',
|
|
1761
|
+
};
|
|
1762
|
+
// Deterministic content-hash id (patternRecordId), computed from the SAME object recordPattern is
|
|
1763
|
+
// about to write — the id is a pure function of {pattern, ts, reward, domain, type}, so the value
|
|
1764
|
+
// computed here and the value the store assigns are guaranteed equal without a round-trip read.
|
|
1765
|
+
const beaconId = patternRecordId(beaconPattern);
|
|
1766
|
+
const probePrompt = `${PROBE_PROMPT_WORDS} ${queryToken}`;
|
|
1767
|
+
const removeBeacon = opts.removeBeacon ?? removePatternsByIds;
|
|
1768
|
+
// AM-2 (HIGH, fix round 1): `wrote` is armed BEFORE the write is even attempted, and cleanup below
|
|
1769
|
+
// runs off `wrote` alone — a `recordPattern` call that PARTIALLY lands and then rejects used to
|
|
1770
|
+
// skip cleanup entirely (the old code's `finally` only wrapped the code AFTER a successful
|
|
1771
|
+
// `await recordPattern`), leaking the beacon forever. A cleanup FAILURE (the store refuses the
|
|
1772
|
+
// delete) now overrides whatever `result` the probe body computed — `ok: true` is not honest if
|
|
1773
|
+
// the probe cannot even prove the store is clean afterward.
|
|
1774
|
+
let wrote = false;
|
|
1775
|
+
let cleanupFailed = false;
|
|
1776
|
+
let cleanupErrMsg = '';
|
|
1777
|
+
let tempCwd;
|
|
1778
|
+
let result;
|
|
1779
|
+
// Codex round-2: a process killed mid-probe bypasses `finally`, so a beacon can outlive its probe.
|
|
1780
|
+
// Every probe therefore starts by SCAVENGING any beacon left behind by an earlier one (the probe
|
|
1781
|
+
// domain is reserved for beacons, never for user lessons) — the store is clean before AND after.
|
|
1782
|
+
try {
|
|
1783
|
+
// a loaded pattern carries the STORE's own id (`dzId`); recomputing it from normalised fields
|
|
1784
|
+
// (type/ts round-trip) can diverge, so the store id wins and the recomputation is the fallback.
|
|
1785
|
+
const stale = loadStorePatternsSync(root).filter((p) => p.domain === PROBE_BEACON_DOMAIN).map((p) => p.dzId ?? patternRecordId(p));
|
|
1786
|
+
if (stale.length > 0)
|
|
1787
|
+
removeBeacon(root, new Set(stale));
|
|
1788
|
+
}
|
|
1789
|
+
catch { /* scavenging is best-effort; the probe's own cleanup below is the accountable path */ }
|
|
1790
|
+
try {
|
|
1791
|
+
wrote = true;
|
|
1792
|
+
let writeFailed;
|
|
1793
|
+
try {
|
|
1794
|
+
await recordPattern(root, beaconPattern);
|
|
1795
|
+
}
|
|
1796
|
+
catch (err) {
|
|
1797
|
+
writeFailed = err instanceof Error ? err.message : String(err);
|
|
1798
|
+
}
|
|
1799
|
+
if (writeFailed !== undefined) {
|
|
1800
|
+
result = { ok: false, reason: `beacon write failed: ${writeFailed}`, elapsedMs: elapsed() };
|
|
1801
|
+
}
|
|
1802
|
+
else {
|
|
1803
|
+
// AM-4: a bare empty temp dir does not model a FOREIGN session — a real foreign
|
|
1804
|
+
// CLAUDE_PROJECT_DIR names a DIFFERENT project with its own (empty-of-lessons, but present)
|
|
1805
|
+
// `.dz`/`.claude` tree, not "nothing at all". This closes the gap between "no project" and "a
|
|
1806
|
+
// different, empty project" a bare empty dir cannot distinguish, matching what the hook's own
|
|
1807
|
+
// SESSION_ROOT-derived reads (e.g. the retro-debt sentinel) would see in a real foreign session.
|
|
1808
|
+
tempCwd = mkdtempSync(join(tmpdir(), 'dz-apply-leg-probe-'));
|
|
1809
|
+
mkdirSync(join(tempCwd, '.dz'), { recursive: true });
|
|
1810
|
+
mkdirSync(join(tempCwd, '.claude'), { recursive: true });
|
|
1811
|
+
const { probeHookLiveness } = await import('./operations.js');
|
|
1812
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
1813
|
+
const probeResult = probeHookLiveness(command, JSON.stringify({ prompt: probePrompt }), {
|
|
1814
|
+
cwd: tempCwd,
|
|
1815
|
+
env: { ...(opts.env ?? {}), CLAUDE_PROJECT_DIR: tempCwd },
|
|
1816
|
+
timeoutMs,
|
|
1817
|
+
});
|
|
1818
|
+
const stdoutLines = probeResult.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
1819
|
+
let additionalContext;
|
|
1820
|
+
for (const line of stdoutLines) {
|
|
1821
|
+
try {
|
|
1822
|
+
const parsed = JSON.parse(line);
|
|
1823
|
+
if (typeof parsed?.hookSpecificOutput?.additionalContext === 'string') {
|
|
1824
|
+
additionalContext = parsed.hookSpecificOutput.additionalContext;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
catch {
|
|
1828
|
+
/* not a JSON line — the hook only ever emits at most one, but tolerate stray output */
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (typeof additionalContext === 'string' && additionalContext.includes(secretToken)) {
|
|
1832
|
+
result = { ok: true, elapsedMs: elapsed() };
|
|
1833
|
+
}
|
|
1834
|
+
else if (typeof additionalContext === 'string' && additionalContext.includes(queryToken)) {
|
|
1835
|
+
// AM-1: the QUERY came back but the SECRET did not — the hook (or a stub standing in for
|
|
1836
|
+
// it) echoed its own input instead of genuinely querying the store. Named distinctly from
|
|
1837
|
+
// every other red reason so a dead leg and a FAKING one never read the same.
|
|
1838
|
+
result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed() };
|
|
1839
|
+
}
|
|
1840
|
+
else {
|
|
1841
|
+
// FR-1's own reason line is the authoritative source — the hook names itself why it stayed
|
|
1842
|
+
// quiet. Falling back to a raw stderr/status summary keeps the probe honest even against an
|
|
1843
|
+
// OLDER deployed hook (pre-`apply-leg-never-silent`) that has not been upgraded yet.
|
|
1844
|
+
const skipMatch = /\[dz-recall\] skipped reason=(\S+)/.exec(probeResult.stderr);
|
|
1845
|
+
const skipReason = skipMatch?.[1];
|
|
1846
|
+
if (skipReason !== undefined) {
|
|
1847
|
+
result = { ok: false, reason: skipReason, elapsedMs: elapsed() };
|
|
1848
|
+
}
|
|
1849
|
+
else if (probeResult.status === null) {
|
|
1850
|
+
result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed() };
|
|
1851
|
+
}
|
|
1852
|
+
else {
|
|
1853
|
+
const stderrFirstLine = probeResult.stderr.trim().split('\n')[0];
|
|
1854
|
+
result = {
|
|
1855
|
+
ok: false,
|
|
1856
|
+
reason: stderrFirstLine && stderrFirstLine !== '' ? stderrFirstLine : 'no beacon in additionalContext (empty or non-matching reply)',
|
|
1857
|
+
elapsedMs: elapsed(),
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
finally {
|
|
1864
|
+
if (tempCwd !== undefined) {
|
|
1865
|
+
try {
|
|
1866
|
+
rmSync(tempCwd, { recursive: true, force: true });
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
/* best-effort cleanup of the probe's own temp cwd */
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
// Beacon removal is UNCONDITIONAL on `wrote` — success, failure, or a thrown probe all reach
|
|
1873
|
+
// here (AM-2). `removePatternsByIds` never throws (patterns.ts's own contract) — a failure is
|
|
1874
|
+
// reported through its RETURN VALUE's `.error`, checked below, never via a catch.
|
|
1875
|
+
if (wrote) {
|
|
1876
|
+
try {
|
|
1877
|
+
const removeResult = removeBeacon(root, new Set([beaconId]));
|
|
1878
|
+
if (removeResult.error !== undefined) {
|
|
1879
|
+
cleanupFailed = true;
|
|
1880
|
+
cleanupErrMsg = removeResult.error;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
catch (err) {
|
|
1884
|
+
// Codex round-2: a remover that THROWS (a foreign store implementation, a test seam) must not
|
|
1885
|
+
// escape past the cleanup accounting — it is a cleanup failure like any other.
|
|
1886
|
+
cleanupFailed = true;
|
|
1887
|
+
cleanupErrMsg = err instanceof Error ? err.message : String(err);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (cleanupFailed) {
|
|
1892
|
+
return {
|
|
1893
|
+
ok: false,
|
|
1894
|
+
reason: `beacon-cleanup-failed: beacon ${beaconId} could not be removed (${cleanupErrMsg})`,
|
|
1895
|
+
elapsedMs: elapsed(),
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
return result;
|
|
1899
|
+
}
|
|
1088
1900
|
//# sourceMappingURL=apply-leg.js.map
|