@dzhechkov/harness-core 0.8.34 → 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/dist/apply-leg.js CHANGED
@@ -26,10 +26,16 @@
26
26
  *
27
27
  * @packageDocumentation
28
28
  */
29
- import { existsSync, readFileSync } from 'node:fs';
29
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
30
30
  import { connect as netConnect } from 'node:net';
31
- import { join } from 'node:path';
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';
32
37
  import { hookCommandsOf } from './managed-hooks.js';
38
+ import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync } from './patterns.js';
33
39
  /**
34
40
  * FR-6 (feature `hook-recall-hybrid-parity`, ADR-001 C-4): send ONE `op: recall` probe to a LIVE
35
41
  * embed daemon socket and report the `engine` it answers with (`'hybrid'` | `'cosine-fallback'`) —
@@ -143,8 +149,52 @@ export function probeRecallEngine(socketPath, timeoutMs = 1000) {
143
149
  * fallback rather than a bare protocol error (AM-3); (d) reports the RAW core RRF score, unchanged,
144
150
  * instead of a locally re-normalized [0,1] value (AM-5) — the hook's own `HOOK_SCORE_FLOOR` default
145
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.
146
196
  */
147
- export const APPLY_LEG_VERSION = 6;
197
+ export const APPLY_LEG_VERSION = 10;
148
198
  /**
149
199
  * Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
150
200
  * `writerVersionOf` (which floors an absent stamp at `0`), this returns `-1` for "no stamp at
@@ -234,7 +284,24 @@ const crypto = require('node:crypto');
234
284
  const os = require('node:os');
235
285
  const { pathToFileURL } = require('node:url');
236
286
 
237
- const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
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();
238
305
  const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
239
306
 
240
307
  // embed-socket-short-path (FR-1/FR-2): byte-for-byte the same logic as \`resolveEmbedSocketPath\` /
@@ -270,6 +337,11 @@ function resolveEffectiveEmbedSocketPath(projectRoot, env) {
270
337
  return pointer !== undefined && fs.existsSync(pointer) ? { path: pointer, reason: 'tmpdir-short' } : resolved;
271
338
  }
272
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';
273
345
  const USAGE_LOG = process.env.DZ_RECALL_USAGE_LOG || path.join(PROJECT, '.dz', 'recall-usage.jsonl');
274
346
  // Measured (2026-09-14, apply-leg-socket.test.ts): an ordinary hook round-trip (spawn + one socket
275
347
  // op) took 81-121 ms; 800 ms leaves a wide margin for a loaded daemon while still bounding the AM-2
@@ -362,7 +434,9 @@ async function loadPolicy() {
362
434
  * - stale / other-session sentinel ⇒ '' (the debt belongs to a dead session; retro collected it);
363
435
  * - core module absent or old (no directive exports) ⇒ '' — inert, NEVER-BLOCK.
364
436
  */
365
- const RETRO_PENDING = path.join(PROJECT, '.dz', 'retro-pending.json');
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');
366
440
  async function retroDebtDirective(payload) {
367
441
  if (!fs.existsSync(RETRO_PENDING)) return '';
368
442
  const sentinel = safe(() => JSON.parse(fs.readFileSync(RETRO_PENDING, 'utf-8')), undefined);
@@ -417,9 +491,13 @@ function readLogTail(chain, file) {
417
491
  // FR-6 (hook-recall-hybrid-parity): the reply now carries \`engine\`/\`reason\` alongside \`hits\` —
418
492
  // returned as a small object rather than the bare hit array, so the caller can apply the RIGHT
419
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.
420
498
  function askDaemon(prompt) {
421
499
  return new Promise((resolve) => {
422
- if (!fs.existsSync(SOCKET)) return resolve(undefined);
500
+ if (!fs.existsSync(SOCKET)) return resolve({ error: 'socket-absent' });
423
501
  let settled = false;
424
502
  const done = (v) => {
425
503
  if (settled) return;
@@ -428,29 +506,29 @@ function askDaemon(prompt) {
428
506
  resolve(v);
429
507
  };
430
508
  const sock = net.connect(SOCKET);
431
- const timer = setTimeout(() => done(undefined), TIMEOUT_MS);
509
+ const timer = setTimeout(() => done({ error: 'daemon-timeout' }), TIMEOUT_MS);
432
510
  timer.unref?.();
433
511
  let buf = '';
434
- 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'));
435
513
  sock.on('data', (chunk) => {
436
514
  buf += chunk.toString('utf8');
437
515
  const nl = buf.indexOf('\\n');
438
516
  if (nl === -1) return;
439
517
  clearTimeout(timer);
440
518
  const msg = safe(() => JSON.parse(buf.slice(0, nl)), undefined);
441
- done(
442
- msg && Array.isArray(msg.hits)
443
- ? {
444
- hits: msg.hits,
445
- engine: typeof msg.engine === 'string' ? msg.engine : undefined,
446
- reason: typeof msg.reason === 'string' ? msg.reason : undefined,
447
- }
448
- : undefined,
449
- );
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
+ }
450
528
  });
451
529
  sock.on('error', () => {
452
530
  clearTimeout(timer);
453
- done(undefined);
531
+ done({ error: 'connect-refused' });
454
532
  });
455
533
  });
456
534
  }
@@ -458,6 +536,13 @@ function askDaemon(prompt) {
458
536
  const REVIVE_LOCK = path.join(PROJECT, '.dz', 'embed-daemon.lock');
459
537
  const REVIVE_LOCK_FRESH_MS = 120_000; // model load takes ~45s; don't respawn while one is coming up
460
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;
461
546
  // CROSS-PROCESS lock: every prompt runs a fresh hook process, so a per-process flag let 20 queued
462
547
  // prompts spawn 20 daemons while the first was still loading its model (Codex #5). A lockfile with
463
548
  // a freshness window means at most one spawn per window, machine-wide.
@@ -623,6 +708,17 @@ function emitContext(context) {
623
708
  );
624
709
  }
625
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
+
626
722
  async function main() {
627
723
  const raw = readStdin();
628
724
  const payload = safe(() => JSON.parse(String(raw || '').trim()), undefined);
@@ -633,13 +729,34 @@ async function main() {
633
729
  // sentinel is absent this is one existsSync and debt === '' (byte-identical outputs to before).
634
730
  const debt = await retroDebtDirective(payload);
635
731
 
636
- if (prompt === '') return emitContext(debt);
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
+ }
637
746
 
638
747
  const policy = await loadPolicy();
639
- if (!policy) return emitContext(debt);
748
+ if (!policy) {
749
+ skip('core-unavailable');
750
+ return emitContext(debt);
751
+ }
640
752
 
641
753
  const daemonReply = await askDaemon(prompt);
642
- if (!daemonReply) {
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);
643
760
  // SELF-HEAL (2026-07-28): the daemon is started at SessionStart only, so when it dies mid-way
644
761
  // through a long-lived session NOTHING restarts it — the apply leg was silently dead for 19
645
762
  // days (MEASURED: recall-usage.jsonl last record 2026-07-09, socket absent). Spawn it
@@ -651,9 +768,12 @@ async function main() {
651
768
  // FR-6/FR-2: the engine (and, on fallback, why) is the caller's business, not the model's — it
652
769
  // NEVER rides into additionalContext, only stderr, which Claude Code does not read as context.
653
770
  if (typeof engine === 'string') {
654
- safe(() => process.stderr.write(\`[dz-recall] engine=\${engine}\${reason ? \` reason=\${reason}\` : ''}\\n\`));
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
655
776
  }
656
- if (hits.length === 0) return emitContext(debt); // daemon alive, nothing relevant — silence is correct
657
777
 
658
778
  // FR-5 (ADR-001 D2): a hybrid-engine reply carries an RRF-based score — its OWN floor, applied to
659
779
  // both languages. A cosine-fallback reply (or an old daemon that never sent \`engine\` at all)
@@ -768,14 +888,19 @@ import { createRequire } from 'node:module';
768
888
  import { connect } from 'node:net';
769
889
  import { createHash } from 'node:crypto';
770
890
  import { tmpdir } from 'node:os';
771
- import { pathToFileURL } from 'node:url';
891
+ import { pathToFileURL, fileURLToPath } from 'node:url';
772
892
 
773
893
  const log = (...a) => console.error('[dz-embed]', ...a);
774
894
 
775
895
  /** Never let a diagnostic reach stdout — the hook that spawns us may be parsing it. */
776
896
  console.log = (...a) => console.error(...a);
777
897
 
778
- const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? process.cwd();
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());
779
904
 
780
905
  // ADR-001 (hook-recall-hybrid-parity, D1): the SAME candidate-list resolution the hook uses for its
781
906
  // own policy modules — the baked \`coreDistDir\` first (a real install's absolute dist path), then
@@ -952,6 +1077,27 @@ function quarantinedOf(metadataJson) {
952
1077
  }
953
1078
  }
954
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
+
955
1101
  async function main() {
956
1102
  if (await socketAlive(SOCKET)) {
957
1103
  log('a daemon already owns', SOCKET, '— exiting');
@@ -1001,7 +1147,9 @@ async function main() {
1001
1147
  return rows.map((r) => {
1002
1148
  const buf = r.embedding;
1003
1149
  const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
1004
- return { dzId: dzIdOf(r.metadata), pattern: r.pattern, vec, quarantined: quarantinedOf(r.metadata) };
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) };
1005
1153
  });
1006
1154
  } finally {
1007
1155
  db.close();
@@ -1027,7 +1175,7 @@ async function main() {
1027
1175
  // \`hybridRecall\`'s own promise is left running past a timeout loss (never awaited a second time)
1028
1176
  // — its \`.catch\` below only silences a LATE rejection so a slow, eventually-failing engine call
1029
1177
  // can never become an unhandled-rejection crash for this long-lived process.
1030
- async function hybridRecall(prompt, limit) {
1178
+ async function hybridRecall(prompt, limit, probe) {
1031
1179
  if (hybridInFlight >= HYBRID_MAX_IN_FLIGHT) {
1032
1180
  return { ok: false, reason: \`hybrid saturated (\${hybridInFlight} attempt(s) still in flight, cap \${HYBRID_MAX_IN_FLIGHT})\` };
1033
1181
  }
@@ -1067,7 +1215,10 @@ async function main() {
1067
1215
  domain: h.pattern.domain,
1068
1216
  ...(h.quarantined ? { quarantined: true } : {}),
1069
1217
  }));
1070
- return { ok: true, hits: hits.slice(0, limit) };
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) };
1071
1222
  } catch (err) {
1072
1223
  clearTimeout(timer);
1073
1224
  return { ok: false, reason: \`recallHybrid failed: \${err?.message ?? err}\` };
@@ -1076,10 +1227,10 @@ async function main() {
1076
1227
 
1077
1228
  /** \`op: recall\`'s whole answer: hybrid first (budget-bounded), cosine fallback on ANY failure —
1078
1229
  * always honestly labelled with \`engine\`/\`reason\` (FR-2/FR-6). */
1079
- async function answerRecall(prompt, limitRaw) {
1230
+ async function answerRecall(prompt, limitRaw, probe) {
1080
1231
  const limit = Math.min(Number(limitRaw) || 8, 32);
1081
1232
  if (prompt.trim() === '') return { hits: [], engine: 'none', reason: 'empty prompt' }; // Codex round-2: every reply carries \`engine\`
1082
- const hybrid = await hybridRecall(prompt, limit);
1233
+ const hybrid = await hybridRecall(prompt, limit, probe);
1083
1234
  if (hybrid.ok) return { hits: hybrid.hits, engine: 'hybrid' };
1084
1235
  // Reload the cosine mirror if it changed on disk (a \`dz teach\` between turns) — the SAME
1085
1236
  // staleness window as before this feature, just checked only when actually falling back.
@@ -1093,9 +1244,10 @@ async function main() {
1093
1244
  }
1094
1245
  if (patterns.length === 0) return { hits: [], engine: 'cosine-fallback', reason: hybrid.reason };
1095
1246
  const qv = await embed(prompt);
1096
- const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), ...(p.quarantined ? { quarantined: true } : {}) }));
1247
+ const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), domain: p.domain, ...(p.quarantined ? { quarantined: true } : {}) }));
1097
1248
  scored.sort((a, b) => b.score - a.score);
1098
- return { hits: scored.slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
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 };
1099
1251
  }
1100
1252
 
1101
1253
  // AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
@@ -1156,7 +1308,7 @@ async function main() {
1156
1308
  return shutdown(0);
1157
1309
  } else if (msg.op === 'recall') {
1158
1310
  const prompt = typeof msg.prompt === 'string' ? msg.prompt : '';
1159
- reply = await answerRecall(prompt, msg.limit);
1311
+ reply = await answerRecall(prompt, msg.limit, msg.probe === true);
1160
1312
  } else {
1161
1313
  reply = { error: \`unknown op \${String(msg.op)}\` };
1162
1314
  }
@@ -1251,25 +1403,101 @@ main().catch((err) => {
1251
1403
  });
1252
1404
  `;
1253
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
+ }
1254
1416
  /**
1255
1417
  * The two hook-registry entries `runSetup` merges into `.claude/settings.json` (FR-1). Commands
1256
1418
  * match the hub's own `.claude/settings.json` verbatim (`grep`-diffed against it at authoring time):
1257
1419
  * the recall hook is invoked with a swallowed non-zero exit (`|| true`) so a broken hook body never
1258
1420
  * fails a prompt, and the embed daemon is spawned detached via `nohup` + a backgrounding `sh -c`
1259
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.
1260
1449
  */
1261
- 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`;
1262
1479
  return {
1263
1480
  userPromptSubmit: {
1264
1481
  hooks: [{
1265
1482
  type: 'command',
1266
- command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/recall-hook.cjs" 2>/dev/null || true',
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`,
1267
1489
  }],
1268
1490
  },
1269
1491
  sessionStart: {
1270
1492
  hooks: [{
1271
1493
  type: 'command',
1272
- command: "sh -c 'nohup node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/dz-embed-daemon.mjs\" >/dev/null 2>&1 & exit 0'",
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)}`,
1273
1501
  }],
1274
1502
  },
1275
1503
  };
@@ -1295,10 +1523,29 @@ function readHelperStatus(path) {
1295
1523
  * Codex, third pass). A bare mention (`echo .claude/helpers/recall-hook.cjs`) is not an invocation:
1296
1524
  * the helper path must follow a `node` word — directly, or inside the daemon's
1297
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.
1298
1534
  */
1299
1535
  export function hookCommandInvokes(command, markerPath) {
1300
- const invoked = new RegExp('(^|[\\s;&|(])node\\s+[\'"]?[^\\s\'"]*' + markerPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'));
1301
- return invoked.test(command);
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);
1302
1549
  }
1303
1550
  /**
1304
1551
  * A hook-registry entry naming `markerPath` is wired under `event` in the PARSED settings structure
@@ -1401,4 +1648,253 @@ export function applyLegReasonMessage(status) {
1401
1648
  }
1402
1649
  return 'not installed — run dz setup --target claude-code --memory agentdb';
1403
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
+ }
1404
1900
  //# sourceMappingURL=apply-leg.js.map