@dzhechkov/harness-core 0.8.34 → 0.8.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.dz-manifest.json +61 -61
  2. package/README.md +255 -12
  3. package/dist/agentdb-index.d.ts +22 -1
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +156 -6
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +180 -2
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +781 -38
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-hooks-assets.d.ts.map +1 -1
  12. package/dist/codex-hooks-assets.js +67 -5
  13. package/dist/codex-hooks-assets.js.map +1 -1
  14. package/dist/codex-hooks.d.ts +13 -1
  15. package/dist/codex-hooks.d.ts.map +1 -1
  16. package/dist/codex-hooks.js +13 -1
  17. package/dist/codex-hooks.js.map +1 -1
  18. package/dist/index.d.ts +8 -6
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +9 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/mutation-gate.d.ts +19 -0
  23. package/dist/mutation-gate.d.ts.map +1 -1
  24. package/dist/mutation-gate.js +37 -1
  25. package/dist/mutation-gate.js.map +1 -1
  26. package/dist/operations.d.ts +17 -1
  27. package/dist/operations.d.ts.map +1 -1
  28. package/dist/operations.js +88 -9
  29. package/dist/operations.js.map +1 -1
  30. package/dist/publish.d.ts +59 -7
  31. package/dist/publish.d.ts.map +1 -1
  32. package/dist/publish.js +205 -32
  33. package/dist/publish.js.map +1 -1
  34. package/dist/release-line.d.ts +16 -0
  35. package/dist/release-line.d.ts.map +1 -1
  36. package/dist/release-line.js +31 -0
  37. package/dist/release-line.js.map +1 -1
  38. package/dist/setup.d.ts.map +1 -1
  39. package/dist/setup.js +90 -14
  40. package/dist/setup.js.map +1 -1
  41. package/dist/skills.d.ts +87 -3
  42. package/dist/skills.d.ts.map +1 -1
  43. package/dist/skills.js +266 -15
  44. package/dist/skills.js.map +1 -1
  45. package/dist/vector-tier.d.ts +34 -3
  46. package/dist/vector-tier.d.ts.map +1 -1
  47. package/dist/vector-tier.js +117 -22
  48. package/dist/vector-tier.js.map +1 -1
  49. package/package.json +2 -2
  50. package/sbom.json +60 -60
  51. package/src/agentdb-index.ts +158 -7
  52. package/src/apply-leg.ts +824 -38
  53. package/src/codex-hooks-assets.ts +67 -5
  54. package/src/codex-hooks.ts +13 -1
  55. package/src/index.ts +15 -2
  56. package/src/mutation-gate.ts +58 -2
  57. package/src/operations.ts +91 -10
  58. package/src/publish.ts +247 -30
  59. package/src/release-line.ts +32 -0
  60. package/src/setup.ts +81 -16
  61. package/src/skills.ts +303 -14
  62. package/src/vector-tier.ts +147 -24
package/src/apply-leg.ts CHANGED
@@ -27,10 +27,16 @@
27
27
  * @packageDocumentation
28
28
  */
29
29
 
30
- import { existsSync, readFileSync } from 'node:fs';
30
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
31
31
  import { connect as netConnect } from 'node:net';
32
- import { join } from 'node:path';
32
+ // `node:os` is NOT one of the modules `countIoImports` (core-boundary.ts) tracks — free to import
33
+ // (core-boundary.test.ts's ratchet only counts fs/child_process/https). `probeApplyLeg`'s temp probe
34
+ // cwd reuses the existing top-level 'node:fs' import above (mkdtempSync/rmSync added to that SAME
35
+ // import statement, not a new one) so the ratchet stays at its pinned files:63 imports:69.
36
+ import { tmpdir, uptime } from 'node:os';
37
+ import { join, resolve } from 'node:path';
33
38
  import { hookCommandsOf } from './managed-hooks.js';
39
+ import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync, type PatternRecord, type RemovePatternsResult } from './patterns.js';
34
40
 
35
41
  /**
36
42
  * FR-6 (feature `hook-recall-hybrid-parity`, ADR-001 C-4): send ONE `op: recall` probe to a LIVE
@@ -140,8 +146,52 @@ export function probeRecallEngine(socketPath: string, timeoutMs = 1000): Promise
140
146
  * fallback rather than a bare protocol error (AM-3); (d) reports the RAW core RRF score, unchanged,
141
147
  * instead of a locally re-normalized [0,1] value (AM-5) — the hook's own `HOOK_SCORE_FLOOR` default
142
148
  * moved from `0.01` to `0.005` to match (see that constant's own comment for the measurement).
149
+ *
150
+ * Bumped 6→7 (feature `apply-leg-install-root`, ADR-001 D1): both generated files now resolve
151
+ * `PROJECT` install-root-first (`INSTALL_ROOT` = this file's own location, when it owns a `.dz/`)
152
+ * instead of trusting a foreign session's `CLAUDE_PROJECT_DIR`/cwd (issue #2) — the hook's own
153
+ * `[dz-recall]` diagnostic line also gains `root=<path> (install|env|cwd)`.
154
+ *
155
+ * Bumped 7→8 (`apply-leg-install-root`, fix round 1, AM-7 HIGH — real regression MEASURED via
156
+ * `retro-debt-hook.test.ts` going 4/5 red on the v7 hub helper): `PROJECT` install-root-first is
157
+ * correct for the STORE (pattern db, socket, daemon script) but WRONG for a per-session artifact —
158
+ * the narrated-error retro-debt sentinel (`retro-pending.json`) is written by the INVOKING
159
+ * SESSION's own Stop hook under `CLAUDE_PROJECT_DIR`, not under wherever the hook happens to be
160
+ * installed; a shared $HOME install made the hook look for a foreign session's sentinel under the
161
+ * install root and silently drop every session's own debt confrontation. Split: `PROJECT` (install-
162
+ * root-first) stays the STORE root; a new `SESSION_ROOT` (`CLAUDE_PROJECT_DIR || cwd()`, the
163
+ * pre-feature resolution, unchanged) is the root for `RETRO_PENDING` — the ONE per-session file this
164
+ * hook reads (every other `PROJECT`-derived path in this file names the store, the daemon, or the
165
+ * harness-core install, confirmed by grep against every `path.join(PROJECT, …)` site). The diag line
166
+ * gains `session=<path>` alongside the existing `root=<path> (…)`.
167
+ *
168
+ * Bumped 8→9 (feature `apply-leg-never-silent`, ADR-001 D2, FR-1): every early return in `main()`
169
+ * now prints `[dz-recall] skipped reason=<store-not-found|socket-absent|core-unavailable|
170
+ * empty-prompt|no-hits> root=<path> (…) session=<path>` on stderr before returning — the hook used
171
+ * to exit silently on every one of these paths, indistinguishable (from stderr alone) from a
172
+ * correctly-quiet "nothing relevant" outcome. `embedDaemonSource`'s own bytes are UNCHANGED by this
173
+ * bump; the shared version number still advances because both helpers are upgraded as one unit by
174
+ * `dz setup`/`applyLegStatus`.
175
+ *
176
+ * Bumped 9→10 (`apply-leg-never-silent`, fix round 1 — cross-model review AM-3/AM-6): (a) the hook
177
+ * now tags its `op: recall` request with `probe: <bool>` (true only when
178
+ * `DZ_HOOK_LIVENESS_PROBE=1`, the env {@link "./operations.js".probeHookLiveness} already stamps on
179
+ * every live-probe spawn) so the daemon can tell a genuine session prompt apart from
180
+ * `probeApplyLeg`'s own beacon query — AM-3: a beacon written for the ~8s of a doctor/parity probe
181
+ * used to be recallable by ANY concurrent real prompt in the SAME project, a probe-only fixture
182
+ * leaking into a real session's context; (b) `askDaemon`'s every failure path used to collapse into
183
+ * one `undefined`, forcing the hook's own `skip('socket-absent')` call regardless of what actually
184
+ * went wrong — AM-6: it now returns a tagged `{error: 'socket-absent'|'connect-refused'|
185
+ * 'daemon-timeout'|'bad-reply'}` so the stderr reason names the ACTUAL failure (no socket file vs a
186
+ * non-socket file at that path vs a listener that never replies vs a listener that replies with
187
+ * something unparseable/shapeless). `embedDaemonSource`'s bytes also change for AM-3: `loadPatterns`
188
+ * now reads each row's `domain` out of the SAME metadata JSON `dzIdOf`/`quarantinedOf` already
189
+ * parse (the vector mirror carries no separate domain column), and both `hybridRecall`'s hits and
190
+ * the cosine-fallback `scored` array are filtered to exclude `domain === 'apply-leg-probe'` unless
191
+ * the request carried `probe: true` — a probe's own beacon still needs to reach ITS query, only a
192
+ * REAL prompt must never see it.
143
193
  */
144
- export const APPLY_LEG_VERSION = 6;
194
+ export const APPLY_LEG_VERSION = 10;
145
195
 
146
196
  /**
147
197
  * Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
@@ -231,7 +281,24 @@ const crypto = require('node:crypto');
231
281
  const os = require('node:os');
232
282
  const { pathToFileURL } = require('node:url');
233
283
 
234
- const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
284
+ // FR-1 (ADR-001 D1, apply-leg-install-root): resolve OUR OWN store from where this hook is
285
+ // INSTALLED, not from whatever project the invoking session happens to be in — a hook installed at
286
+ // $HOME (a common single-machine layout: \`dz setup --target claude --memory agentdb --project
287
+ // $HOME\`, expecting the leg everywhere) used to silently look up a DIFFERENT project's \`.dz/\`
288
+ // whenever CLAUDE_PROJECT_DIR pointed elsewhere (issue #2, MEASURED). Precedent:
289
+ // claude-hooks-assets.ts's \`path.resolve(__dirname, '..', '..')\` for the destructive-guard hook.
290
+ // Order: INSTALL_ROOT (if it owns a \`.dz/\`) -> CLAUDE_PROJECT_DIR -> cwd.
291
+ const INSTALL_ROOT = path.resolve(__dirname, '..', '..');
292
+ const ROOT_SOURCE = fs.existsSync(path.join(INSTALL_ROOT, '.dz')) ? 'install' : (process.env.CLAUDE_PROJECT_DIR ? 'env' : 'cwd');
293
+ const PROJECT = ROOT_SOURCE === 'install' ? INSTALL_ROOT : (process.env.CLAUDE_PROJECT_DIR || process.cwd());
294
+ // AM-7 (fix round 1, apply-leg-install-root): PROJECT above is the STORE root (install-first) — a
295
+ // pattern db shared across every session at a $HOME install is correctly install-scoped. A
296
+ // per-session ARTIFACT is the opposite: the retro-debt sentinel is written by THIS SESSION's own
297
+ // Stop hook under its own CLAUDE_PROJECT_DIR, so looking it up under a foreign install root finds
298
+ // nothing (or, worse, another session's leftover file) and silently drops the confrontation.
299
+ // SESSION_ROOT is the pre-feature resolution, unchanged — the root for any file that belongs to the
300
+ // INVOKING session rather than to the store.
301
+ const SESSION_ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
235
302
  const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
236
303
 
237
304
  // embed-socket-short-path (FR-1/FR-2): byte-for-byte the same logic as \`resolveEmbedSocketPath\` /
@@ -267,6 +334,11 @@ function resolveEffectiveEmbedSocketPath(projectRoot, env) {
267
334
  return pointer !== undefined && fs.existsSync(pointer) ? { path: pointer, reason: 'tmpdir-short' } : resolved;
268
335
  }
269
336
  const SOCKET = resolveEffectiveEmbedSocketPath(PROJECT, process.env).path;
337
+ // AM-3 (fix round 1, apply-leg-never-silent): set ONLY by probeHookLiveness (operations.ts) on
338
+ // every live-probe spawn — never by a real Claude Code session. Rides into the daemon's op:recall
339
+ // request as \`probe\` so the daemon can admit the probe's OWN beacon (domain=apply-leg-probe)
340
+ // without ever surfacing it to a concurrent real prompt in the same project.
341
+ const IS_LIVENESS_PROBE = process.env.DZ_HOOK_LIVENESS_PROBE === '1';
270
342
  const USAGE_LOG = process.env.DZ_RECALL_USAGE_LOG || path.join(PROJECT, '.dz', 'recall-usage.jsonl');
271
343
  // Measured (2026-09-14, apply-leg-socket.test.ts): an ordinary hook round-trip (spawn + one socket
272
344
  // op) took 81-121 ms; 800 ms leaves a wide margin for a loaded daemon while still bounding the AM-2
@@ -359,7 +431,9 @@ async function loadPolicy() {
359
431
  * - stale / other-session sentinel ⇒ '' (the debt belongs to a dead session; retro collected it);
360
432
  * - core module absent or old (no directive exports) ⇒ '' — inert, NEVER-BLOCK.
361
433
  */
362
- const RETRO_PENDING = path.join(PROJECT, '.dz', 'retro-pending.json');
434
+ // AM-7 (fix round 1): SESSION_ROOT, not PROJECT — the sentinel is a per-session artifact (see the
435
+ // SESSION_ROOT comment above).
436
+ const RETRO_PENDING = path.join(SESSION_ROOT, '.dz', 'retro-pending.json');
363
437
  async function retroDebtDirective(payload) {
364
438
  if (!fs.existsSync(RETRO_PENDING)) return '';
365
439
  const sentinel = safe(() => JSON.parse(fs.readFileSync(RETRO_PENDING, 'utf-8')), undefined);
@@ -414,9 +488,13 @@ function readLogTail(chain, file) {
414
488
  // FR-6 (hook-recall-hybrid-parity): the reply now carries \`engine\`/\`reason\` alongside \`hits\` —
415
489
  // returned as a small object rather than the bare hit array, so the caller can apply the RIGHT
416
490
  // floor (FR-5) and print the engine to stderr ONLY (never into the injected context, FR-2).
491
+ // AM-6 (fix round 1): every failure used to collapse into one \`undefined\`, forcing the caller's
492
+ // \`skip('socket-absent')\` regardless of whether the socket was truly absent, refused the connect,
493
+ // never replied, or replied with garbage. Each branch now tags its OWN reason so the stderr line
494
+ // (and, through it, \`probeApplyLeg\`'s parsed reason) names what actually happened.
417
495
  function askDaemon(prompt) {
418
496
  return new Promise((resolve) => {
419
- if (!fs.existsSync(SOCKET)) return resolve(undefined);
497
+ if (!fs.existsSync(SOCKET)) return resolve({ error: 'socket-absent' });
420
498
  let settled = false;
421
499
  const done = (v) => {
422
500
  if (settled) return;
@@ -425,29 +503,29 @@ function askDaemon(prompt) {
425
503
  resolve(v);
426
504
  };
427
505
  const sock = net.connect(SOCKET);
428
- const timer = setTimeout(() => done(undefined), TIMEOUT_MS);
506
+ const timer = setTimeout(() => done({ error: 'daemon-timeout' }), TIMEOUT_MS);
429
507
  timer.unref?.();
430
508
  let buf = '';
431
- sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8 }) + '\\n'));
509
+ sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8, probe: IS_LIVENESS_PROBE }) + '\\n'));
432
510
  sock.on('data', (chunk) => {
433
511
  buf += chunk.toString('utf8');
434
512
  const nl = buf.indexOf('\\n');
435
513
  if (nl === -1) return;
436
514
  clearTimeout(timer);
437
515
  const msg = safe(() => JSON.parse(buf.slice(0, nl)), undefined);
438
- done(
439
- msg && Array.isArray(msg.hits)
440
- ? {
441
- hits: msg.hits,
442
- engine: typeof msg.engine === 'string' ? msg.engine : undefined,
443
- reason: typeof msg.reason === 'string' ? msg.reason : undefined,
444
- }
445
- : undefined,
446
- );
516
+ if (msg && Array.isArray(msg.hits)) {
517
+ done({
518
+ hits: msg.hits,
519
+ engine: typeof msg.engine === 'string' ? msg.engine : undefined,
520
+ reason: typeof msg.reason === 'string' ? msg.reason : undefined,
521
+ });
522
+ } else {
523
+ done({ error: 'bad-reply' });
524
+ }
447
525
  });
448
526
  sock.on('error', () => {
449
527
  clearTimeout(timer);
450
- done(undefined);
528
+ done({ error: 'connect-refused' });
451
529
  });
452
530
  });
453
531
  }
@@ -455,6 +533,13 @@ function askDaemon(prompt) {
455
533
  const REVIVE_LOCK = path.join(PROJECT, '.dz', 'embed-daemon.lock');
456
534
  const REVIVE_LOCK_FRESH_MS = 120_000; // model load takes ~45s; don't respawn while one is coming up
457
535
  function reviveDaemon() {
536
+ // AM-7 (fix round 1, test-only): a test that probes the SAME dead fixture multiple times in quick
537
+ // succession (probeApplyLeg directly, then again through runDoctor, then again through a
538
+ // 'dz parity' subprocess) used to race against this very self-heal — the first probe's revive
539
+ // could finish loading a real daemon before the second or third probe ran, flipping
540
+ // "socket-absent" into "hybrid"/"cosine-fallback" non-deterministically. No production session
541
+ // ever sets this.
542
+ if (process.env.DZ_RECALL_NO_REVIVE === '1') return;
458
543
  // CROSS-PROCESS lock: every prompt runs a fresh hook process, so a per-process flag let 20 queued
459
544
  // prompts spawn 20 daemons while the first was still loading its model (Codex #5). A lockfile with
460
545
  // a freshness window means at most one spawn per window, machine-wide.
@@ -620,6 +705,17 @@ function emitContext(context) {
620
705
  );
621
706
  }
622
707
 
708
+ // FR-1 (ADR-001 D2, apply-leg-never-silent): every silent early exit below now names WHY, on
709
+ // stderr, one line, same shape as the existing \`[dz-recall] engine=…\` diagnostic. Exit code stays
710
+ // 0 — a broken/empty/quiet hook must never fail a prompt (NEVER-BLOCK, unchanged). The reason is
711
+ // for TWO readers, neither of which is "the user watching Claude Code's own transcript" (FR-2's
712
+ // own manifest names why that channel does not apply to this hook): (1) \`dz doctor\`'s live probe,
713
+ // which spawns this exact command as a child process and reads ITS OWN child's stderr directly —
714
+ // unmediated by Claude Code's UI, so the redirect policy of any particular hook EVENT is moot; and
715
+ // (2) a human running the command by hand from a terminal, who sees stderr exactly as printed.
716
+ const skip = (reason) =>
717
+ safe(() => process.stderr.write(\`[dz-recall] skipped reason=\${reason} root=\${PROJECT} (\${ROOT_SOURCE}) session=\${SESSION_ROOT}\\n\`));
718
+
623
719
  async function main() {
624
720
  const raw = readStdin();
625
721
  const payload = safe(() => JSON.parse(String(raw || '').trim()), undefined);
@@ -630,13 +726,34 @@ async function main() {
630
726
  // sentinel is absent this is one existsSync and debt === '' (byte-identical outputs to before).
631
727
  const debt = await retroDebtDirective(payload);
632
728
 
633
- if (prompt === '') return emitContext(debt);
729
+ if (prompt === '') {
730
+ skip('empty-prompt');
731
+ return emitContext(debt);
732
+ }
733
+
734
+ // FR-1: the most fundamental silent failure (issue #2) — no \`.dz/\` at all under the resolved
735
+ // PROJECT root. Checked BEFORE the policy/daemon legs below: a missing store makes every
736
+ // downstream question ("is the daemon alive?") moot, and printing THIS reason first is what let
737
+ // the original issue's symptom (four green checks, a store that was never there) be diagnosed
738
+ // from stderr alone.
739
+ if (!fs.existsSync(path.join(PROJECT, '.dz'))) {
740
+ skip('store-not-found');
741
+ return emitContext(debt);
742
+ }
634
743
 
635
744
  const policy = await loadPolicy();
636
- if (!policy) return emitContext(debt);
745
+ if (!policy) {
746
+ skip('core-unavailable');
747
+ return emitContext(debt);
748
+ }
637
749
 
638
750
  const daemonReply = await askDaemon(prompt);
639
- if (!daemonReply) {
751
+ // AM-6: the tagged reason IS the diagnosis now — 'socket-absent' (no file), 'connect-refused' (a
752
+ // file exists but nothing answers like a daemon), 'daemon-timeout' (something answers, never in
753
+ // time) and 'bad-reply' (answers, unparseable/shapeless) are four DIFFERENT defects with four
754
+ // different remedies; collapsing them back into one string is exactly the finding this fixes.
755
+ if (daemonReply.error) {
756
+ skip(daemonReply.error);
640
757
  // SELF-HEAL (2026-07-28): the daemon is started at SessionStart only, so when it dies mid-way
641
758
  // through a long-lived session NOTHING restarts it — the apply leg was silently dead for 19
642
759
  // days (MEASURED: recall-usage.jsonl last record 2026-07-09, socket absent). Spawn it
@@ -648,9 +765,12 @@ async function main() {
648
765
  // FR-6/FR-2: the engine (and, on fallback, why) is the caller's business, not the model's — it
649
766
  // NEVER rides into additionalContext, only stderr, which Claude Code does not read as context.
650
767
  if (typeof engine === 'string') {
651
- safe(() => process.stderr.write(\`[dz-recall] engine=\${engine}\${reason ? \` reason=\${reason}\` : ''}\\n\`));
768
+ safe(() => process.stderr.write(\`[dz-recall] engine=\${engine}\${reason ? \` reason=\${reason}\` : ''} root=\${PROJECT} (\${ROOT_SOURCE}) session=\${SESSION_ROOT}\\n\`));
769
+ }
770
+ if (hits.length === 0) {
771
+ skip('no-hits');
772
+ return emitContext(debt); // daemon alive, nothing relevant — silence is correct
652
773
  }
653
- if (hits.length === 0) return emitContext(debt); // daemon alive, nothing relevant — silence is correct
654
774
 
655
775
  // FR-5 (ADR-001 D2): a hybrid-engine reply carries an RRF-based score — its OWN floor, applied to
656
776
  // both languages. A cosine-fallback reply (or an old daemon that never sent \`engine\` at all)
@@ -774,14 +894,19 @@ import { createRequire } from 'node:module';
774
894
  import { connect } from 'node:net';
775
895
  import { createHash } from 'node:crypto';
776
896
  import { tmpdir } from 'node:os';
777
- import { pathToFileURL } from 'node:url';
897
+ import { pathToFileURL, fileURLToPath } from 'node:url';
778
898
 
779
899
  const log = (...a) => console.error('[dz-embed]', ...a);
780
900
 
781
901
  /** Never let a diagnostic reach stdout — the hook that spawns us may be parsing it. */
782
902
  console.log = (...a) => console.error(...a);
783
903
 
784
- const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? process.cwd();
904
+ // FR-4 (ADR-001 D1, apply-leg-install-root): the SAME install-root-first order the hook uses (see
905
+ // recallHookSource's own PROJECT comment) — DZ_PROJECT_ROOT stays the TOP override for the daemon
906
+ // (a caller that explicitly names a project root always wins), then INSTALL_ROOT (this file's own
907
+ // location, when it owns a \`.dz/\`), then cwd.
908
+ const INSTALL_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
909
+ const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? (existsSync(join(INSTALL_ROOT, '.dz')) ? INSTALL_ROOT : process.cwd());
785
910
 
786
911
  // ADR-001 (hook-recall-hybrid-parity, D1): the SAME candidate-list resolution the hook uses for its
787
912
  // own policy modules — the baked \`coreDistDir\` first (a real install's absolute dist path), then
@@ -958,6 +1083,27 @@ function quarantinedOf(metadataJson) {
958
1083
  }
959
1084
  }
960
1085
 
1086
+ // AM-3 (fix round 1, apply-leg-never-silent): the vector mirror's own row carries no domain column
1087
+ // (see loadPatterns' own note below) — domain lives in the SAME metadata JSON dzIdOf/quarantinedOf
1088
+ // already parse, so this is the ONE extra field read off a column that's already in hand.
1089
+ function domainOf(metadataJson) {
1090
+ try {
1091
+ const d = JSON.parse(String(metadataJson || '{}'))?.domain;
1092
+ return typeof d === 'string' && d !== '' ? d : undefined;
1093
+ } catch {
1094
+ return undefined;
1095
+ }
1096
+ }
1097
+
1098
+ // AM-3: the ONE domain a genuine session must never see — probeApplyLeg's own beacon marker
1099
+ // (apply-leg.ts's PROBE_BEACON_DOMAIN, inlined here as text for the same reason every other shared
1100
+ // constant in this generated file is: a template string cannot import a compiled module).
1101
+ const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
1102
+ /** Strip probe-domain hits from a real (non-probe) answer; a probe request passes through untouched. */
1103
+ function filterProbeHits(hits, probe) {
1104
+ return probe ? hits : hits.filter((h) => h.domain !== PROBE_BEACON_DOMAIN);
1105
+ }
1106
+
961
1107
  async function main() {
962
1108
  if (await socketAlive(SOCKET)) {
963
1109
  log('a daemon already owns', SOCKET, '— exiting');
@@ -1007,7 +1153,9 @@ async function main() {
1007
1153
  return rows.map((r) => {
1008
1154
  const buf = r.embedding;
1009
1155
  const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
1010
- return { dzId: dzIdOf(r.metadata), pattern: r.pattern, vec, quarantined: quarantinedOf(r.metadata) };
1156
+ // AM-3: domain rides along so answerRecall can exclude the probe's own beacon from a real
1157
+ // session's cosine-fallback answer — the SAME metadata column dzIdOf/quarantinedOf already read.
1158
+ return { dzId: dzIdOf(r.metadata), pattern: r.pattern, vec, quarantined: quarantinedOf(r.metadata), domain: domainOf(r.metadata) };
1011
1159
  });
1012
1160
  } finally {
1013
1161
  db.close();
@@ -1033,7 +1181,7 @@ async function main() {
1033
1181
  // \`hybridRecall\`'s own promise is left running past a timeout loss (never awaited a second time)
1034
1182
  // — its \`.catch\` below only silences a LATE rejection so a slow, eventually-failing engine call
1035
1183
  // can never become an unhandled-rejection crash for this long-lived process.
1036
- async function hybridRecall(prompt, limit) {
1184
+ async function hybridRecall(prompt, limit, probe) {
1037
1185
  if (hybridInFlight >= HYBRID_MAX_IN_FLIGHT) {
1038
1186
  return { ok: false, reason: \`hybrid saturated (\${hybridInFlight} attempt(s) still in flight, cap \${HYBRID_MAX_IN_FLIGHT})\` };
1039
1187
  }
@@ -1073,7 +1221,10 @@ async function main() {
1073
1221
  domain: h.pattern.domain,
1074
1222
  ...(h.quarantined ? { quarantined: true } : {}),
1075
1223
  }));
1076
- return { ok: true, hits: hits.slice(0, limit) };
1224
+ // AM-3 (fix round 1): filter BEFORE slicing to \`limit\` — filtering after would let a probe
1225
+ // beacon that happened to rank in the top \`limit\` silently crowd out a real hit for a real
1226
+ // (non-probe) caller instead of simply being excluded from consideration.
1227
+ return { ok: true, hits: filterProbeHits(hits, probe).slice(0, limit) };
1077
1228
  } catch (err) {
1078
1229
  clearTimeout(timer);
1079
1230
  return { ok: false, reason: \`recallHybrid failed: \${err?.message ?? err}\` };
@@ -1082,10 +1233,10 @@ async function main() {
1082
1233
 
1083
1234
  /** \`op: recall\`'s whole answer: hybrid first (budget-bounded), cosine fallback on ANY failure —
1084
1235
  * always honestly labelled with \`engine\`/\`reason\` (FR-2/FR-6). */
1085
- async function answerRecall(prompt, limitRaw) {
1236
+ async function answerRecall(prompt, limitRaw, probe) {
1086
1237
  const limit = Math.min(Number(limitRaw) || 8, 32);
1087
1238
  if (prompt.trim() === '') return { hits: [], engine: 'none', reason: 'empty prompt' }; // Codex round-2: every reply carries \`engine\`
1088
- const hybrid = await hybridRecall(prompt, limit);
1239
+ const hybrid = await hybridRecall(prompt, limit, probe);
1089
1240
  if (hybrid.ok) return { hits: hybrid.hits, engine: 'hybrid' };
1090
1241
  // Reload the cosine mirror if it changed on disk (a \`dz teach\` between turns) — the SAME
1091
1242
  // staleness window as before this feature, just checked only when actually falling back.
@@ -1099,9 +1250,10 @@ async function main() {
1099
1250
  }
1100
1251
  if (patterns.length === 0) return { hits: [], engine: 'cosine-fallback', reason: hybrid.reason };
1101
1252
  const qv = await embed(prompt);
1102
- const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), ...(p.quarantined ? { quarantined: true } : {}) }));
1253
+ const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), domain: p.domain, ...(p.quarantined ? { quarantined: true } : {}) }));
1103
1254
  scored.sort((a, b) => b.score - a.score);
1104
- return { hits: scored.slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
1255
+ // AM-3: same domain exclusion as the hybrid leg, applied before slicing for the same reason.
1256
+ return { hits: filterProbeHits(scored, probe).slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
1105
1257
  }
1106
1258
 
1107
1259
  // AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
@@ -1162,7 +1314,7 @@ async function main() {
1162
1314
  return shutdown(0);
1163
1315
  } else if (msg.op === 'recall') {
1164
1316
  const prompt = typeof msg.prompt === 'string' ? msg.prompt : '';
1165
- reply = await answerRecall(prompt, msg.limit);
1317
+ reply = await answerRecall(prompt, msg.limit, msg.probe === true);
1166
1318
  } else {
1167
1319
  reply = { error: \`unknown op \${String(msg.op)}\` };
1168
1320
  }
@@ -1263,25 +1415,104 @@ export interface ApplyLegHookEntry {
1263
1415
  readonly hooks: readonly { readonly type: 'command'; readonly command: string }[];
1264
1416
  }
1265
1417
 
1418
+ /**
1419
+ * POSIX single-quote a value for safe interpolation into a shell command line: wraps it in `'`,
1420
+ * escaping every embedded `'` as the standard `'\''` sequence (close quote, literal escaped quote,
1421
+ * reopen quote). Single quotes disable EVERY shell expansion — `$`, backticks, `"`, another `'` —
1422
+ * unlike a bare `"..."` interpolation, which blocks only whitespace/globbing and still lets `$`/
1423
+ * backtick content run (AM-1, fix round 1, HIGH).
1424
+ */
1425
+ function shellQuote(value: string): string {
1426
+ return `'${value.split(`'`).join(`'\\''`)}'`;
1427
+ }
1428
+
1266
1429
  /**
1267
1430
  * The two hook-registry entries `runSetup` merges into `.claude/settings.json` (FR-1). Commands
1268
1431
  * match the hub's own `.claude/settings.json` verbatim (`grep`-diffed against it at authoring time):
1269
1432
  * the recall hook is invoked with a swallowed non-zero exit (`|| true`) so a broken hook body never
1270
1433
  * fails a prompt, and the embed daemon is spawned detached via `nohup` + a backgrounding `sh -c`
1271
1434
  * so `SessionStart` never waits on model load.
1435
+ *
1436
+ * `installRoot` (ADR-001 D2, feature `apply-leg-install-root`): when the caller (`dz setup`) knows
1437
+ * its own install root, the commands bake it in as an ABSOLUTE path — the deployed helper already
1438
+ * bakes an absolute `CORE_DIST_DIR`, so a `${CLAUDE_PROJECT_DIR:-.}`-relative command in
1439
+ * settings.json only masked that non-portability, and broke down to `Cannot find module` (swallowed
1440
+ * by `2>/dev/null || true`) whenever `project === $HOME` and a session's own `CLAUDE_PROJECT_DIR`
1441
+ * pointed elsewhere (issue #2). Omitting `installRoot` (every pre-existing zero-arg caller — status
1442
+ * fixtures, `applyLegStatus` regression tests) keeps the original `CLAUDE_PROJECT_DIR`-relative
1443
+ * form byte for byte; `hookCommandInvokes`/`applyLegStatus` (FR-3) recognize BOTH forms as wired,
1444
+ * and `runSetup`'s `addIfMissing` (setup.ts) REPLACES a stale form with the current one in place —
1445
+ * never a second entry — on re-setup.
1446
+ *
1447
+ * Fix round 1 corrections to the absolute (`installRoot`-given) branch — the legacy zero-arg branch
1448
+ * is UNCHANGED byte for byte:
1449
+ * - AM-1 (HIGH): a caller-controlled path was interpolated RAW into shell source. A `"`, `$`,
1450
+ * backtick, or `'` in `installRoot` altered or injected commands, and the SessionStart form broke
1451
+ * outright on a `'` (it cannot be escaped inside a `'...'` body by nesting `"`). Fixed:
1452
+ * {@link shellQuote} wraps every path; SessionStart passes them as POSITIONAL ARGS (`$1`/`$2`) to
1453
+ * an INNER `sh -c` whose script text is a FIXED literal with no caller-controlled bytes, so
1454
+ * nested-quote fragility cannot arise at all.
1455
+ * - AM-3 (HIGH): the daemon used to fall back to `DZ_PROJECT_ROOT ?? installLocal`, and nothing in
1456
+ * the SessionStart command ever SET that variable — a stale inherited `DZ_PROJECT_ROOT` in the
1457
+ * parent env could win over the install root the hook itself resolves to. Fixed: the SessionStart
1458
+ * command now sets `DZ_PROJECT_ROOT="$1"` (`$1` = installRoot) explicitly, so the daemon and the
1459
+ * hook agree by construction regardless of what the parent environment happens to carry.
1460
+ * - AM-4 (LOW): a relative `installRoot` used to produce a relative command, breaking the "every
1461
+ * baked path is absolute" invariant the module's own docs claim. Fixed: `resolve()`s its input.
1272
1462
  */
1273
- export function applyLegHookEntries(): { readonly userPromptSubmit: ApplyLegHookEntry; readonly sessionStart: ApplyLegHookEntry } {
1463
+ export function applyLegHookEntries(
1464
+ installRoot?: string,
1465
+ ): { readonly userPromptSubmit: ApplyLegHookEntry; readonly sessionStart: ApplyLegHookEntry } {
1466
+ if (installRoot === undefined) {
1467
+ // Legacy zero-arg form — byte-identical to every pre-fix-round build. Kept only for
1468
+ // `applyLegStatus`'s upgrade-recognition tests (a pre-feature install's settings.json) and for
1469
+ // seeding "stale entry" fixtures; every real `dz setup` caller passes `opts.projectRoot`.
1470
+ return {
1471
+ userPromptSubmit: {
1472
+ hooks: [{
1473
+ type: 'command',
1474
+ // FR-2 (apply-leg-never-silent, ADR-001 D2): `2>/dev/null` removed — the hook itself now
1475
+ // names every silent exit on stderr (FR-1), and swallowing that stream at the settings.json
1476
+ // level would defeat it at the source. `|| true` stays: a broken hook body must never fail
1477
+ // the prompt.
1478
+ command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/recall-hook.cjs" || true',
1479
+ }],
1480
+ },
1481
+ sessionStart: {
1482
+ hooks: [{
1483
+ type: 'command',
1484
+ command: "sh -c 'nohup node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/dz-embed-daemon.mjs\" >/dev/null 2>&1 & exit 0'",
1485
+ }],
1486
+ },
1487
+ };
1488
+ }
1489
+ // AM-4: make a relative caller input absolute so the "every baked path is absolute" invariant
1490
+ // holds regardless of what the caller passed, not merely for callers that already resolve first.
1491
+ const root = resolve(installRoot);
1492
+ const recallHookPath = `${root}/.claude/helpers/recall-hook.cjs`;
1493
+ const daemonPath = `${root}/.claude/helpers/dz-embed-daemon.mjs`;
1274
1494
  return {
1275
1495
  userPromptSubmit: {
1276
1496
  hooks: [{
1277
1497
  type: 'command',
1278
- command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/recall-hook.cjs" 2>/dev/null || true',
1498
+ // AM-1: shellQuote the WHOLE path — a bare `"..."` interpolation only blocks whitespace and
1499
+ // globbing, it still lets `$`, backticks, and a literal `"` do damage; single-quoting blocks
1500
+ // every shell expansion at once.
1501
+ // FR-2 (apply-leg-never-silent, ADR-001 D2): `2>/dev/null` removed — see the zero-arg branch's
1502
+ // comment above for why.
1503
+ command: `node ${shellQuote(recallHookPath)} || true`,
1279
1504
  }],
1280
1505
  },
1281
1506
  sessionStart: {
1282
1507
  hooks: [{
1283
1508
  type: 'command',
1284
- command: "sh -c 'nohup node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/dz-embed-daemon.mjs\" >/dev/null 2>&1 & exit 0'",
1509
+ // AM-1/AM-3: the INNER script text (`'DZ_PROJECT_ROOT="$1" nohup node "$2" …'`) is a FIXED
1510
+ // literal — no caller-controlled byte ever sits inside it, so it can never itself contain an
1511
+ // unescaped `'` that would break the outer single-quoting. `root`/`daemonPath` instead arrive
1512
+ // as POSITIONAL ARGS (`$1`/`$2`), each independently shellQuote()d for the OUTER shell that
1513
+ // parses this whole command line. AM-3: `DZ_PROJECT_ROOT="$1"` pins the daemon to THIS
1514
+ // install root explicitly — a stale value already in the parent environment can never win.
1515
+ command: `sh -c 'DZ_PROJECT_ROOT="$1" nohup node "$2" >/dev/null 2>&1 & exit 0' sh ${shellQuote(root)} ${shellQuote(daemonPath)}`,
1285
1516
  }],
1286
1517
  },
1287
1518
  };
@@ -1357,10 +1588,27 @@ function readHelperStatus(path: string): { exists: boolean; version: number; unr
1357
1588
  * Codex, third pass). A bare mention (`echo .claude/helpers/recall-hook.cjs`) is not an invocation:
1358
1589
  * the helper path must follow a `node` word — directly, or inside the daemon's
1359
1590
  * `sh -c 'nohup node "…"'` spawn. Forward slashes only: every command dz writes uses them.
1591
+ *
1592
+ * Fix round 1 (AM-1/AM-3, apply-leg-install-root): the SessionStart command now passes its daemon
1593
+ * path as a POSITIONAL ARG (`sh -c '… node "$2" …' sh <root> <daemonPath>`) rather than interpolating
1594
+ * it textually next to `node`, so the ORIGINAL adjacency regex alone no longer matches it. A SECOND
1595
+ * recognizer accepts that shape: the command invokes `node` with a `$N`-style positional argument
1596
+ * AND carries `markerPath` as one of its own (shellQuote()d) trailing arguments — both conditions
1597
+ * together, so a foreign command that merely echoes the marker path near an unrelated `node "$1"`
1598
+ * invocation still does not count.
1360
1599
  */
1361
1600
  export function hookCommandInvokes(command: string, markerPath: string): boolean {
1362
- const invoked = new RegExp('(^|[\\s;&|(])node\\s+[\'"]?[^\\s\'"]*' + markerPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'));
1363
- return invoked.test(command);
1601
+ const escapedMarker = markerPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
1602
+ const direct = new RegExp('(^|[\\s;&|(])node\\s+[\'"]?[^\\s\'"]*' + escapedMarker);
1603
+ if (direct.test(command)) return true;
1604
+ // Codex round-2 (NEW): the absolute form is POSIX-single-quoted (`node '<root>/.claude/…'`), and a
1605
+ // root may contain whitespace or `'` (written as `'\''`) — the bare `[^\s'"]*` run above stops at
1606
+ // the first space, so such an entry read as "absent" and re-setup appended a duplicate.
1607
+ const quotedDirect = new RegExp("(^|[\\s;&|(])node\\s+'(?:[^']|'\\\\'')*" + escapedMarker);
1608
+ if (quotedDirect.test(command)) return true;
1609
+ const invokesNodeWithPositional = /\bnode\s+["']?\$\d/.test(command);
1610
+ const markerAsQuotedArg = new RegExp("'[^']*" + escapedMarker + "'");
1611
+ return invokesNodeWithPositional && markerAsQuotedArg.test(command);
1364
1612
  }
1365
1613
 
1366
1614
  /**
@@ -1468,3 +1716,541 @@ export function applyLegReasonMessage(status: ApplyLegStatus): string {
1468
1716
  }
1469
1717
  return 'not installed — run dz setup --target claude-code --memory agentdb';
1470
1718
  }
1719
+
1720
+ /**
1721
+ * The ACTUAL command `.claude/settings.json` carries for the wired `UserPromptSubmit` recall hook —
1722
+ * not a reconstruction. {@link probeApplyLeg} must run exactly what a real session would run,
1723
+ * `${CLAUDE_PROJECT_DIR:-.}`-relative legacy form and all: reconstructing our own `node <path> ||
1724
+ * true` would silently stop testing the shell-expansion half of the legacy form, the exact half
1725
+ * issue #2 broke. Mirrors {@link hookWiredUnder}'s traversal (kept in lock-step: both read
1726
+ * `hooks.UserPromptSubmit[*].hooks[*].command` and recognize it via {@link hookCommandInvokes}) but
1727
+ * returns the command TEXT instead of a boolean.
1728
+ */
1729
+ function findConfiguredRecallHookCommand(root: string): string | undefined {
1730
+ try {
1731
+ const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8')) as unknown;
1732
+ const hooksSection = (settings as { hooks?: unknown })?.hooks;
1733
+ const list = hooksSection && typeof hooksSection === 'object' ? (hooksSection as Record<string, unknown>)['UserPromptSubmit'] : undefined;
1734
+ if (!Array.isArray(list)) return undefined;
1735
+ for (const entry of list) {
1736
+ for (const cmd of hookCommandsOf(entry)) {
1737
+ if (hookCommandInvokes(cmd, '.claude/helpers/recall-hook.cjs')) return cmd;
1738
+ }
1739
+ }
1740
+ } catch {
1741
+ /* settings.json absent, unreadable, or not valid JSON — nothing to probe */
1742
+ }
1743
+ return undefined;
1744
+ }
1745
+
1746
+ /**
1747
+ * AM-4 (fix round 1, apply-leg-never-silent): the LEGACY zero-arg form ({@link applyLegHookEntries}'s
1748
+ * no-installRoot branch) reads `${CLAUDE_PROJECT_DIR:-.}` — a shell expansion that only resolves to
1749
+ * something useful from a REAL session's own cwd. Spawning it from `probeApplyLeg`'s temp "foreign"
1750
+ * cwd can never find the deployed helper by construction (the file lives at `root`'s own
1751
+ * `.claude/helpers/`, never under the temp dir), so a probe against this form would spawn a doomed
1752
+ * command and report a confusing generic failure — not a fact about whether the leg injects, only a
1753
+ * fact about the fixture being unprobeable. The absolute form ({@link shellQuote}'d installRoot)
1754
+ * never contains this literal env-expansion syntax — it bakes a resolved path instead — so a plain
1755
+ * substring check distinguishes the two without re-parsing shell grammar.
1756
+ */
1757
+ export function isLegacyRelativeRecallCommand(command: string): boolean {
1758
+ return command.includes('${CLAUDE_PROJECT_DIR');
1759
+ }
1760
+
1761
+ /** {@link probeApplyLeg}'s result — the ONE measurement `dz doctor`'s new row and `dz parity`'s
1762
+ * Self-learning cell both read (ADR-001 Decision 3, extended by `apply-leg-never-silent` D1): green
1763
+ * means OBSERVED injection, never inferred file presence. */
1764
+ export interface ApplyLegProbeResult {
1765
+ /** True ONLY when the probe's own beacon lesson came back inside `additionalContext`. */
1766
+ readonly ok: boolean;
1767
+ /** Present exactly when `ok` is `false` — taken from the hook's own `[dz-recall] skipped
1768
+ * reason=…` stderr line when present, else a best-effort description of what went wrong. */
1769
+ readonly reason?: string;
1770
+ readonly elapsedMs: number;
1771
+ /** Fix round 1 (HIGH-1): the KILL-ATTEMPT fact, straight from `probeHookLiveness`'s own
1772
+ * `groupKillAttempted` — present only on the branches that actually reached a spawn (absent for the
1773
+ * early "not installed" / "no hook configured" / "legacy command" / "beacon write failed"
1774
+ * returns, none of which ever call `probeHookLiveness`). This is deliberately a DIFFERENT fact
1775
+ * from "is the grandchild still alive" — a caller observes both separately, never conflates them. */
1776
+ readonly groupKillAttempted?: boolean;
1777
+ /** FR-3 (feature `apply-leg-daemon-hygiene`): present exactly when the pre-probe scavenge of
1778
+ * STALE beacon-domain records failed — the reason comes straight from {@link
1779
+ * scavengeStaleProbeBeacons}'s own `error`, never swallowed. Independent of `ok`/`reason`: a
1780
+ * scavenge failure does not itself flip `ok` to `false` (the probe's own injection result may
1781
+ * still be perfectly genuine), but it IS a fact `dz doctor`/`dz parity` callers can surface
1782
+ * rather than lose. */
1783
+ readonly scavengeError?: string;
1784
+ }
1785
+
1786
+ /** Words a real prompt needs to clear `hasEnoughSignal` (recall-hook-policy.ts: `MIN_PROMPT_CHARS`
1787
+ * 10, `MIN_CONTENT_TOKENS` 2) — a bare unique token alone is ONE token and would be silently
1788
+ * dropped by the very floor this probe means to exercise honestly. */
1789
+ const PROBE_PROMPT_WORDS = 'apply leg live probe';
1790
+ /** Doctor/parity probes share ONE domain tag so a leaked beacon (a failed removal) is trivially
1791
+ * findable and excludable — never `dz-teach`/`general`, which would blend it into real lessons. */
1792
+ const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
1793
+
1794
+ /**
1795
+ * FR-3 (feature `apply-leg-daemon-hygiene`): the scavenger that ran at the top of every probe used
1796
+ * to delete EVERY beacon-domain record unconditionally — safe against a probe killed mid-flight
1797
+ * (Codex round-2's own reason for the scavenger existing at all), but WRONG the moment two probes
1798
+ * from two DIFFERENT sessions can be live against the SAME store at once: the second probe's
1799
+ * scavenge deletes the first probe's still-in-flight beacon, and the first probe then reports a
1800
+ * false `ok:false` (its own hook query returns nothing, because the lesson it was about to match
1801
+ * against is already gone) — a false-red `dz doctor`/`dz parity` parity check with no defect behind
1802
+ * it. The fix: tag every beacon with its OWNER (`probe-owner=<pid>:<startedMs>`, embedded in the
1803
+ * pattern TEXT so it survives a round-trip through any store tier without a schema change — NFR-1
1804
+ * forbids a new column) and scavenge ONLY a beacon whose owner is provably gone: the pid no longer
1805
+ * answers `process.kill(pid, 0)`, OR the beacon has outlived its TTL (see {@link
1806
+ * scavengeStaleProbeBeacons}'s own `ttlMs` parameter).
1807
+ */
1808
+ const PROBE_OWNER_TAG_PREFIX = 'probe-owner=';
1809
+ /** Fix round 1 (HIGH-6 residual): a second, independent tag alongside `probe-owner=` — the WRITING
1810
+ * process's own OS-level start time (epoch ms, from `/proc/<pid>/stat`), so the scavenger can tell a
1811
+ * PID-REUSE case (the recorded pid is technically "alive" per `process.kill(pid,0)`, but the LIVE
1812
+ * process at that pid started at a different time than the one that wrote the beacon) apart from
1813
+ * the genuine same-process case. Absent whenever the write-time `/proc` read fails (non-Linux,
1814
+ * permission) — the scavenger then falls back to the plain alive+TTL check alone, unchanged. */
1815
+ const PROC_START_TAG_PREFIX = 'proc-start=';
1816
+ /** Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry, in epoch ms, written by the
1817
+ * probe that owns it. The pre-delta scavenger applied ITS OWN `ttlMs` to SOMEONE ELSE'S beacon, so a
1818
+ * default-budget probe (ttl 60 s) deleted the live beacon of a widened-budget probe (ttl 360 s) after
1819
+ * 60 s — the very false-red this hardening exists to prevent, one level up. A beacon now states when
1820
+ * IT expires; the scavenger's own `ttlMs` is only the fallback for a beacon written before this tag
1821
+ * existed. */
1822
+ const PROBE_EXPIRES_TAG_PREFIX = 'probe-expires=';
1823
+
1824
+ /**
1825
+ * Fix round 1 (HIGH-6): the pre-fix-round TTL was a flat 60 000 ms, independent of the probe's own
1826
+ * `timeoutMs` — a caller that legitimately widens `timeoutMs` past that (a widened, suspended, or
1827
+ * heavily loaded probe) could have its OWN still-in-flight beacon scavenged by a concurrent probe
1828
+ * before it ever replies. The TTL is now DERIVED from the probe's own `timeoutMs`
1829
+ * (`max(timeoutMs * 3, 60_000)`, see {@link probeApplyLeg}'s call site) so a widened timeout widens
1830
+ * its own protection window too; the 60 000 ms floor keeps the pre-fix-round generous margin for the
1831
+ * default (unwidened) case. Exported as a named constant only for the floor value — the ACTUAL TTL
1832
+ * used by a given probe is always `ttlMs`, computed at the call site, never this constant alone.
1833
+ */
1834
+ const PROBE_BEACON_TTL_FLOOR_MS = 60_000;
1835
+
1836
+ function formatProbeOwner(pid: number, startedMs: number): string {
1837
+ return `${PROBE_OWNER_TAG_PREFIX}${pid}:${startedMs}`;
1838
+ }
1839
+
1840
+ /** Parses the `probe-owner=<pid>:<startedMs>` tag out of a beacon's pattern text. `undefined` for
1841
+ * any beacon predating this tag (an older deployed core wrote it) — treated by the scavenger as
1842
+ * ownerless and therefore always safe to remove (the pre-FR-3 behavior for exactly that case). */
1843
+ function parseProbeOwner(patternText: string): { readonly pid: number; readonly startedMs: number } | undefined {
1844
+ const idx = patternText.indexOf(PROBE_OWNER_TAG_PREFIX);
1845
+ if (idx === -1) return undefined;
1846
+ const match = /probe-owner=(\d+):(\d+)/u.exec(patternText.slice(idx));
1847
+ if (match?.[1] === undefined || match[2] === undefined) return undefined;
1848
+ return { pid: Number(match[1]), startedMs: Number(match[2]) };
1849
+ }
1850
+
1851
+ function formatProcStart(procStartedAtMs: number): string {
1852
+ return ` ${PROC_START_TAG_PREFIX}${procStartedAtMs}`;
1853
+ }
1854
+
1855
+ function formatProbeExpires(expiresAtMs: number): string {
1856
+ return ` ${PROBE_EXPIRES_TAG_PREFIX}${expiresAtMs}`;
1857
+ }
1858
+
1859
+ /** Parses the `probe-expires=<epochMs>` tag — `undefined` for a beacon written before the tag
1860
+ * existed, which is exactly when the scavenger falls back to its own `ttlMs`. */
1861
+ function parseProbeExpires(patternText: string): number | undefined {
1862
+ const idx = patternText.indexOf(PROBE_EXPIRES_TAG_PREFIX);
1863
+ if (idx === -1) return undefined;
1864
+ const match = /probe-expires=(\d+)/u.exec(patternText.slice(idx));
1865
+ if (match?.[1] === undefined) return undefined;
1866
+ return Number(match[1]);
1867
+ }
1868
+
1869
+ /** Parses the `proc-start=<ticks>` tag — `undefined` when absent (pre-fix-round beacon, or the
1870
+ * write-time `/proc` read failed). */
1871
+ function parseProcStart(patternText: string): number | undefined {
1872
+ const idx = patternText.indexOf(PROC_START_TAG_PREFIX);
1873
+ if (idx === -1) return undefined;
1874
+ const match = /proc-start=(\d+)/u.exec(patternText.slice(idx));
1875
+ if (match?.[1] === undefined) return undefined;
1876
+ return Number(match[1]);
1877
+ }
1878
+
1879
+ /** True when `pid` answers a liveness signal — `process.kill(pid, 0)` sends no actual signal, it
1880
+ * only probes whether the OS still has a process at that pid (ESRCH ⇒ dead). */
1881
+ function isPidAlive(pid: number): boolean {
1882
+ try {
1883
+ process.kill(pid, 0);
1884
+ return true;
1885
+ } catch (err) {
1886
+ return (err as NodeJS.ErrnoException).code !== 'ESRCH';
1887
+ }
1888
+ }
1889
+
1890
+ /**
1891
+ * Fix round 1 (HIGH-6 residual, Linux-only, best-effort), tightened by the lead after Codex round 2
1892
+ * (MEDIUM-6): a process's OWN `starttime` from `/proc/<pid>/stat` — field 22 overall, found by
1893
+ * skipping past the LAST `)` so a `comm` containing spaces or parens never misaligns the split —
1894
+ * returned as RAW TICKS SINCE BOOT, the unit the kernel reports. No wall clock is consulted and no
1895
+ * CLK_TCK conversion is performed, so a stepped wall clock can no longer make the same live process
1896
+ * look like a different one. `undefined` on ANY read/parse failure (non-Linux, permission, the
1897
+ * process exiting mid-read) — the caller MUST treat that as "cannot prove", never as a pass in
1898
+ * either direction.
1899
+ */
1900
+ function pidStartedAtMsFromProcStat(pid: number): number | undefined {
1901
+ try {
1902
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
1903
+ const afterComm = stat.slice(stat.lastIndexOf(')') + 1).trim();
1904
+ const fields = afterComm.split(/\s+/u);
1905
+ // Overall field 22 (`starttime`) = fields[22 - 3] here, since fields[] starts at overall field 3
1906
+ // (state) once `pid (comm)` (fields 1-2) has been stripped above.
1907
+ const starttimeTicks = Number(fields[19]);
1908
+ if (!Number.isFinite(starttimeTicks)) return undefined;
1909
+ // Lead delta after Codex round 2 (MEDIUM-6): return the RAW ticks-since-boot, never an epoch
1910
+ // derived from `Date.now() - uptime()`. The derived epoch moves when the WALL CLOCK is stepped,
1911
+ // so the same still-running process appeared to have "started at a different time" and its LIVE
1912
+ // beacon was deleted as a pid-reuse. Ticks since boot are monotonic within a boot and identical
1913
+ // for the same process on every read; across a reboot the recorded pid is dead anyway, which the
1914
+ // liveness check catches first — WITHDRAWN by the lead after Codex round 4: that sentence was
1915
+ // wrong, because across a reboot the pid may ALREADY have been reused and can collide on start
1916
+ // ticks too; the honest scope is the NAMED LIMIT stated below. This also removes the CLK_TCK assumption entirely — no conversion
1917
+ // to milliseconds happens at all, the two values are compared in their own unit.
1918
+ // NAMED LIMIT (lead delta after Codex round 3, MEDIUM): identity holds WITHIN one boot. Across a
1919
+ // reboot both pid allocation and ticks-since-boot restart, so a beacon that somehow persisted
1920
+ // could in principle collide with a new process at the same pid and the same tick. A boot id
1921
+ // would close it; the honest scope today is "within one boot", and a reboot also means the
1922
+ // beacon's own TTL has almost certainly passed, which the TTL branch catches first.
1923
+ return starttimeTicks;
1924
+ } catch {
1925
+ return undefined;
1926
+ }
1927
+ }
1928
+
1929
+ /** Fix round 1 (HIGH-6 residual): `process.kill(pid,0)` proves SOME process occupies `pid`, never
1930
+ * that it is the SAME process that wrote the beacon — PID reuse defeats the plain alive check named
1931
+ * as a residual limit in the review. Compares the LIVE process's own start time (from `/proc/<pid>/
1932
+ * stat`) against `recordedStartedMs` (the beacon's own `proc-start=` tag). A mismatch beyond {@link
1933
+ * PID_START_TOLERANCE_MS} means the pid was reused by an unrelated process — the true owner is
1934
+ * confirmed gone. `undefined` (stat unreadable) means "cannot prove either way" — the lead's decision
1935
+ * (fix round 1, item 6) is explicit: that MUST read as "do not remove" wherever it is consumed, never
1936
+ * as a pass. */
1937
+ function isSameProcessInstance(pid: number, recordedStartTicks: number): boolean | undefined {
1938
+ const actualStartTicks = pidStartedAtMsFromProcStat(pid);
1939
+ if (actualStartTicks === undefined) return undefined;
1940
+ // Lead delta after Codex round 2 (MEDIUM-6): EXACT equality on ticks-since-boot. The old
1941
+ // ±5 000 ms tolerance existed only to absorb the CLK_TCK guess in the epoch conversion; with the
1942
+ // raw kernel value there is nothing to absorb, and a tolerance would re-admit the very pid-reuse
1943
+ // case it was meant to exclude (a reused pid started within the tolerance read as "same process").
1944
+ return actualStartTicks === recordedStartTicks;
1945
+ }
1946
+
1947
+ /** {@link scavengeStaleProbeBeacons}'s result — FR-3: a scavenge error is a FACT the caller can
1948
+ * surface, never a swallowed exception (the pre-fix `catch { /* best-effort *\/ }` this replaces). */
1949
+ export type ScavengeResult = { readonly ok: true; readonly removed: number } | { readonly ok: false; readonly error: string };
1950
+
1951
+ /**
1952
+ * FR-3/FR-4: remove only the STALE beacon-domain records in `root`'s store — owner dead
1953
+ * (`process.kill(pid, 0)` ⇒ ESRCH), older than `ttlMs`, OR (fix round 1, HIGH-6 residual) alive AND
1954
+ * within `ttlMs` but the live pid's OWN `/proc/<pid>/stat` start time no longer matches the beacon's
1955
+ * `proc-start=` tag — a confirmed PID-reuse case, where the recorded owner is provably gone even
1956
+ * though `pid` itself answers. An UNREADABLE stat at scavenge time never counts as reuse evidence —
1957
+ * it falls back to the plain alive+TTL verdict, per the lead's "cannot prove ⇒ do not remove"
1958
+ * decision. A beacon whose owner is alive, within TTL, and (when provable) confirmed the SAME
1959
+ * process is left untouched, even though it belongs to a different probe. An ownerless (pre-FR-3)
1960
+ * beacon is always treated as stale. `ttlMs` defaults to {@link PROBE_BEACON_TTL_FLOOR_MS} for a
1961
+ * caller that does not derive one (`apply-leg-beacon-owner.test.ts`'s own fixtures); `probeApplyLeg`
1962
+ * always passes its own derived value. Exported so `apply-leg-beacon-owner.test.ts` can exercise the
1963
+ * property directly, without needing a live hook/daemon (FR-4's red-first case needs only a store
1964
+ * and an injectable remover, never a real probe round-trip).
1965
+ */
1966
+ export function scavengeStaleProbeBeacons(
1967
+ root: string,
1968
+ removeBeacon: (root: string, ids: ReadonlySet<string>) => RemovePatternsResult,
1969
+ ttlMs: number = PROBE_BEACON_TTL_FLOOR_MS,
1970
+ ): ScavengeResult {
1971
+ try {
1972
+ const beacons = loadStorePatternsSync(root).filter((p) => p.domain === PROBE_BEACON_DOMAIN);
1973
+ const staleIds: string[] = [];
1974
+ for (const beacon of beacons) {
1975
+ const owner = parseProbeOwner(beacon.pattern);
1976
+ if (owner === undefined) {
1977
+ staleIds.push(beacon.dzId ?? patternRecordId(beacon));
1978
+ continue;
1979
+ }
1980
+ const alive = isPidAlive(owner.pid);
1981
+ // Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry wins over this scavenger's
1982
+ // `ttlMs`, which belongs to a DIFFERENT probe and knows nothing of this owner's budget.
1983
+ const ownExpiresAtMs = parseProbeExpires(beacon.pattern);
1984
+ const withinTtl = ownExpiresAtMs !== undefined
1985
+ ? Date.now() <= ownExpiresAtMs
1986
+ : Date.now() - owner.startedMs <= ttlMs;
1987
+ if (!alive || !withinTtl) {
1988
+ staleIds.push(beacon.dzId ?? patternRecordId(beacon));
1989
+ continue;
1990
+ }
1991
+ // alive AND within TTL — the plain check says "keep", but confirm it is the SAME process, not
1992
+ // a reused pid, whenever the beacon carries the (best-effort) proc-start tag.
1993
+ const procStart = parseProcStart(beacon.pattern);
1994
+ if (procStart !== undefined) {
1995
+ const same = isSameProcessInstance(owner.pid, procStart);
1996
+ if (same === false) staleIds.push(beacon.dzId ?? patternRecordId(beacon)); // confirmed reuse
1997
+ // same === true, or same === undefined (unreadable ⇒ cannot prove ⇒ do not remove): keep.
1998
+ }
1999
+ }
2000
+ if (staleIds.length === 0) return { ok: true, removed: 0 };
2001
+ const result = removeBeacon(root, new Set(staleIds));
2002
+ if (result.error !== undefined) return { ok: false, error: result.error };
2003
+ return { ok: true, removed: result.removed };
2004
+ } catch (err) {
2005
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2006
+ }
2007
+ }
2008
+
2009
+ /**
2010
+ * Live, end-to-end proof that the apply leg actually injects — ADR-001 Decision 1. `applyLegStatus`
2011
+ * only proves FILES exist and are STRUCTURALLY wired (issue #2's whole defect: four green checks,
2012
+ * a leg that injected nothing in every session but one). This spawns the REAL configured hook
2013
+ * command from a TEMPORARY cwd with `CLAUDE_PROJECT_DIR` pointing at that same temp dir — the exact
2014
+ * shape of a real Claude Code session, which never runs a hook from the project root itself — and
2015
+ * asks it to recall a throwaway "beacon" lesson written into the store for the duration of the call.
2016
+ * `ok: true` only when the beacon's own SECRET token (fix round 1, AM-1 — never sent as input, only
2017
+ * stored) comes back inside `additionalContext`; every other outcome is `ok: false` with a `reason`,
2018
+ * never a silent guess.
2019
+ *
2020
+ * The beacon is written via {@link recordPattern} (the SAME lexical-store seam `dz teach` uses) and
2021
+ * removed via {@link removePatternsByIds} in a `finally` — a probe that throws, times out, or never
2022
+ * finds the leg alive still leaves the store exactly as it found it (proven by a count-before ==
2023
+ * count-after test, not merely claimed).
2024
+ *
2025
+ * `timeoutMs` bounds `probeHookLiveness`'s spawn. Measured (this environment, 2026-09-14, T1): a
2026
+ * `store-not-found`/`socket-absent` early exit returns in well under 200 ms; a live-daemon probe
2027
+ * answers in ~100-200 ms, matching ADR-001's own estimate. 8000 ms leaves roughly a 40x margin for a
2028
+ * loaded daemon without ever approaching `probeHookLiveness`'s own un-overridden 20 000 ms ceiling —
2029
+ * a genuinely dead probe still returns to `dz doctor`/`dz parity` in bounded time.
2030
+ *
2031
+ * `env` is a TEST-ONLY escape hatch (never used by `dz doctor`/`dz parity`, both call this with
2032
+ * default opts): it lets a test widen the HOOK's OWN internal socket-connect timeout
2033
+ * (`DZ_RECALL_HOOK_TIMEOUT_MS`) against a genuinely cold daemon, matching the same widening
2034
+ * `apply-leg-recall-parity.test.ts`/`apply-leg-install-root.test.ts` already apply to the daemon's
2035
+ * `HOOK_RECALL_BUDGET_MS`. Merged BEFORE `CLAUDE_PROJECT_DIR`, so a caller can never override the
2036
+ * one env var this probe's own honesty depends on.
2037
+ */
2038
+ export async function probeApplyLeg(
2039
+ root: string,
2040
+ opts: {
2041
+ timeoutMs?: number;
2042
+ env?: Readonly<Record<string, string>>;
2043
+ /** TEST-ONLY seam (AM-2, fix round 1): a stand-in for {@link removePatternsByIds} so a test can
2044
+ * force cleanup to fail WITHOUT needing to corrupt the real store mid-call. Never set by
2045
+ * `dz doctor`/`dz parity` — both call with default opts, and the real function is the default. */
2046
+ removeBeacon?: (root: string, ids: ReadonlySet<string>) => RemovePatternsResult;
2047
+ } = {},
2048
+ ): Promise<ApplyLegProbeResult> {
2049
+ const started = Date.now();
2050
+ const elapsed = (): number => Date.now() - started;
2051
+
2052
+ // Fix round 1 (HIGH-6): computed HERE, before the scavenger runs, so the scavenge TTL can be
2053
+ // derived from THIS probe's own budget rather than a flat constant blind to a caller-widened
2054
+ // timeout — see the `ttlMs` computation below, right before the beacon that carries its implicit
2055
+ // promise is written.
2056
+ const timeoutMs = opts.timeoutMs ?? 8000;
2057
+
2058
+ const status = applyLegStatus(root);
2059
+ if (!status.installed) {
2060
+ return { ok: false, reason: 'apply-leg not installed', elapsedMs: elapsed() };
2061
+ }
2062
+ const command = findConfiguredRecallHookCommand(root);
2063
+ if (command === undefined) {
2064
+ return { ok: false, reason: 'no UserPromptSubmit entry invokes recall-hook.cjs (settings.json missing or unreadable)', elapsedMs: elapsed() };
2065
+ }
2066
+ // AM-4: the legacy relative form can never be reached from a foreign cwd by construction — see
2067
+ // isLegacyRelativeRecallCommand's own doc comment. Reported BEFORE any beacon is written: there is
2068
+ // nothing to clean up for a probe that never ran.
2069
+ if (isLegacyRelativeRecallCommand(command)) {
2070
+ return { ok: false, reason: 'legacy-relative-command', elapsedMs: elapsed() };
2071
+ }
2072
+
2073
+ // AM-1 (CRITICAL, fix round 1): two INDEPENDENT tokens, not one. `queryToken` rides the PROMPT the
2074
+ // probe sends the hook — a dead/stub hook that merely echoes its own stdin back into
2075
+ // `additionalContext` makes THIS token reappear too, so it alone can never prove genuine
2076
+ // injection. `secretToken` exists ONLY inside the beacon's STORED pattern text and is never sent
2077
+ // to the hook as input — only a hook that actually queried the store and returned a matched
2078
+ // pattern's own text can produce it. `ok: true` therefore requires the SECRET, never the query.
2079
+ const queryToken = `dzapplylegquery${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
2080
+ const secretToken = `dzapplylegsecret${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
2081
+ // FR-3: the owner tag rides the SAME pattern text as the secret — a beacon's owner is knowable
2082
+ // from its store record alone, no side channel, no schema change (NFR-1).
2083
+ const ownerTag = formatProbeOwner(process.pid, started);
2084
+ // Fix round 1 (HIGH-6): TTL derives from THIS probe's own timeoutMs — a caller that widens
2085
+ // timeoutMs (this repo's own live tests widen it to 15_000 ms) widens its own protection window
2086
+ // too, rather than staying pinned to a flat constant blind to that widening. Floored at the
2087
+ // pre-fix-round 60_000 ms so the default (unwidened) case keeps its original generous margin.
2088
+ // Asserted, not merely trusted — an unreasoned future edit to the formula must fail loudly right
2089
+ // here, where the beacon carrying this TTL's implicit promise is about to be written, rather than
2090
+ // silently reintroducing the false-red-under-concurrency defect this closes.
2091
+ const ttlMs = Math.max(timeoutMs * 3, PROBE_BEACON_TTL_FLOOR_MS);
2092
+ if (!(timeoutMs < ttlMs)) {
2093
+ throw new Error(`invariant violated: timeoutMs (${String(timeoutMs)}) must be < ttlMs (${String(ttlMs)}) — scavenger TTL formula regressed`);
2094
+ }
2095
+ // Fix round 1 (HIGH-6 residual, best-effort): the writing process's OWN OS-level start time,
2096
+ // captured NOW so a later scavenger can tell a genuinely-still-alive owner apart from a DIFFERENT
2097
+ // process that merely reused this pid (isSameProcessInstance's own doc comment). Omitted entirely
2098
+ // when unreadable (non-Linux, permission) — the scavenger then falls back to the plain alive+TTL
2099
+ // check for this beacon, unchanged from before this residual hardening.
2100
+ const ownProcStart = pidStartedAtMsFromProcStat(process.pid);
2101
+ const procStartTag = ownProcStart !== undefined ? formatProcStart(ownProcStart) : '';
2102
+ // Lead delta after Codex round 2 (HIGH-4): this beacon states its OWN expiry, so a concurrent
2103
+ // probe with a different (shorter) budget can never out-vote this probe's protection window.
2104
+ const expiresTag = formatProbeExpires(started + ttlMs);
2105
+ const beaconPattern: PatternRecord = {
2106
+ pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken} ${ownerTag}${procStartTag}${expiresTag}`,
2107
+ type: 'lesson-learned',
2108
+ reward: 0,
2109
+ domain: PROBE_BEACON_DOMAIN,
2110
+ ts: new Date().toISOString(),
2111
+ source: 'apply-leg-probe',
2112
+ };
2113
+ // Deterministic content-hash id (patternRecordId), computed from the SAME object recordPattern is
2114
+ // about to write — the id is a pure function of {pattern, ts, reward, domain, type}, so the value
2115
+ // computed here and the value the store assigns are guaranteed equal without a round-trip read.
2116
+ const beaconId = patternRecordId(beaconPattern);
2117
+ const probePrompt = `${PROBE_PROMPT_WORDS} ${queryToken}`;
2118
+ const removeBeacon = opts.removeBeacon ?? removePatternsByIds;
2119
+
2120
+ // AM-2 (HIGH, fix round 1): `wrote` is armed BEFORE the write is even attempted, and cleanup below
2121
+ // runs off `wrote` alone — a `recordPattern` call that PARTIALLY lands and then rejects used to
2122
+ // skip cleanup entirely (the old code's `finally` only wrapped the code AFTER a successful
2123
+ // `await recordPattern`), leaking the beacon forever. A cleanup FAILURE (the store refuses the
2124
+ // delete) now overrides whatever `result` the probe body computed — `ok: true` is not honest if
2125
+ // the probe cannot even prove the store is clean afterward.
2126
+ let wrote = false;
2127
+ let cleanupFailed = false;
2128
+ let cleanupErrMsg = '';
2129
+ let tempCwd: string | undefined;
2130
+ let result!: ApplyLegProbeResult;
2131
+
2132
+ // Codex round-2: a process killed mid-probe bypasses `finally`, so a beacon can outlive its probe.
2133
+ // Every probe therefore starts by SCAVENGING any beacon left behind by an earlier one (the probe
2134
+ // domain is reserved for beacons, never for user lessons) — the store is clean before AND after.
2135
+ // FR-3/FR-4: scavenging is no longer unconditional — a LIVE beacon from a DIFFERENT concurrent
2136
+ // probe (another session's `dz doctor`/`dz parity` against the same store) must survive; only a
2137
+ // beacon whose owner is dead or past its TTL is removed. A scavenge failure is a named FACT
2138
+ // (`scavengeError`), never a swallowed exception (the pre-fix `catch {}` this replaces).
2139
+ let scavengeError: string | undefined;
2140
+ const scavengeResult = scavengeStaleProbeBeacons(root, removeBeacon, ttlMs);
2141
+ if (!scavengeResult.ok) scavengeError = scavengeResult.error;
2142
+
2143
+ try {
2144
+ wrote = true;
2145
+ let writeFailed: string | undefined;
2146
+ try {
2147
+ await recordPattern(root, beaconPattern);
2148
+ } catch (err) {
2149
+ writeFailed = err instanceof Error ? err.message : String(err);
2150
+ }
2151
+
2152
+ if (writeFailed !== undefined) {
2153
+ result = { ok: false, reason: `beacon write failed: ${writeFailed}`, elapsedMs: elapsed() };
2154
+ } else {
2155
+ // AM-4: a bare empty temp dir does not model a FOREIGN session — a real foreign
2156
+ // CLAUDE_PROJECT_DIR names a DIFFERENT project with its own (empty-of-lessons, but present)
2157
+ // `.dz`/`.claude` tree, not "nothing at all". This closes the gap between "no project" and "a
2158
+ // different, empty project" a bare empty dir cannot distinguish, matching what the hook's own
2159
+ // SESSION_ROOT-derived reads (e.g. the retro-debt sentinel) would see in a real foreign session.
2160
+ tempCwd = mkdtempSync(join(tmpdir(), 'dz-apply-leg-probe-'));
2161
+ mkdirSync(join(tempCwd, '.dz'), { recursive: true });
2162
+ mkdirSync(join(tempCwd, '.claude'), { recursive: true });
2163
+
2164
+ const { probeHookLiveness } = await import('./operations.js');
2165
+ const probeResult = probeHookLiveness(command, JSON.stringify({ prompt: probePrompt }), {
2166
+ cwd: tempCwd,
2167
+ env: { ...(opts.env ?? {}), CLAUDE_PROJECT_DIR: tempCwd },
2168
+ timeoutMs,
2169
+ });
2170
+
2171
+ const stdoutLines = probeResult.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
2172
+ let additionalContext: unknown;
2173
+ for (const line of stdoutLines) {
2174
+ try {
2175
+ const parsed = JSON.parse(line) as { hookSpecificOutput?: { additionalContext?: unknown } };
2176
+ if (typeof parsed?.hookSpecificOutput?.additionalContext === 'string') {
2177
+ additionalContext = parsed.hookSpecificOutput.additionalContext;
2178
+ }
2179
+ } catch {
2180
+ /* not a JSON line — the hook only ever emits at most one, but tolerate stray output */
2181
+ }
2182
+ }
2183
+
2184
+ if (typeof additionalContext === 'string' && additionalContext.includes(secretToken)) {
2185
+ result = { ok: true, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
2186
+ } else if (typeof additionalContext === 'string' && additionalContext.includes(queryToken)) {
2187
+ // AM-1: the QUERY came back but the SECRET did not — the hook (or a stub standing in for
2188
+ // it) echoed its own input instead of genuinely querying the store. Named distinctly from
2189
+ // every other red reason so a dead leg and a FAKING one never read the same.
2190
+ result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
2191
+ } else {
2192
+ // FR-1's own reason line is the authoritative source — the hook names itself why it stayed
2193
+ // quiet. Falling back to a raw stderr/status summary keeps the probe honest even against an
2194
+ // OLDER deployed hook (pre-`apply-leg-never-silent`) that has not been upgraded yet.
2195
+ const skipMatch = /\[dz-recall\] skipped reason=(\S+)/.exec(probeResult.stderr);
2196
+ const skipReason = skipMatch?.[1];
2197
+ if (skipReason !== undefined) {
2198
+ result = { ok: false, reason: skipReason, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
2199
+ } else if (probeResult.status === null) {
2200
+ result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
2201
+ } else {
2202
+ const stderrFirstLine = probeResult.stderr.trim().split('\n')[0];
2203
+ result = {
2204
+ ok: false,
2205
+ reason: stderrFirstLine && stderrFirstLine !== '' ? stderrFirstLine : 'no beacon in additionalContext (empty or non-matching reply)',
2206
+ elapsedMs: elapsed(),
2207
+ groupKillAttempted: probeResult.groupKillAttempted,
2208
+ };
2209
+ }
2210
+ }
2211
+ }
2212
+ } finally {
2213
+ if (tempCwd !== undefined) {
2214
+ try {
2215
+ rmSync(tempCwd, { recursive: true, force: true });
2216
+ } catch {
2217
+ /* best-effort cleanup of the probe's own temp cwd */
2218
+ }
2219
+ }
2220
+ // Beacon removal is UNCONDITIONAL on `wrote` — success, failure, or a thrown probe all reach
2221
+ // here (AM-2). `removePatternsByIds` never throws (patterns.ts's own contract) — a failure is
2222
+ // reported through its RETURN VALUE's `.error`, checked below, never via a catch.
2223
+ if (wrote) {
2224
+ try {
2225
+ const removeResult = removeBeacon(root, new Set([beaconId]));
2226
+ if (removeResult.error !== undefined) {
2227
+ cleanupFailed = true;
2228
+ cleanupErrMsg = removeResult.error;
2229
+ }
2230
+ } catch (err) {
2231
+ // Codex round-2: a remover that THROWS (a foreign store implementation, a test seam) must not
2232
+ // escape past the cleanup accounting — it is a cleanup failure like any other.
2233
+ cleanupFailed = true;
2234
+ cleanupErrMsg = err instanceof Error ? err.message : String(err);
2235
+ }
2236
+ }
2237
+ }
2238
+
2239
+ if (cleanupFailed) {
2240
+ // a cleanup failure can only happen after `result` was assigned (the finally block runs after
2241
+ // the try body) — carry the kill-attempt fact through rather than dropping it on this path.
2242
+ // `exactOptionalPropertyTypes` forbids assigning `undefined` to an optional field explicitly, so
2243
+ // the key is included only when `result.groupKillAttempted` actually has a value.
2244
+ return {
2245
+ ok: false,
2246
+ reason: `beacon-cleanup-failed: beacon ${beaconId} could not be removed (${cleanupErrMsg})`,
2247
+ elapsedMs: elapsed(),
2248
+ ...(result.groupKillAttempted !== undefined ? { groupKillAttempted: result.groupKillAttempted } : {}),
2249
+ ...(scavengeError !== undefined ? { scavengeError } : {}),
2250
+ };
2251
+ }
2252
+ // FR-3: a scavenge failure is surfaced on the SUCCESS path too — it does not override `ok`/
2253
+ // `reason` (this probe's own injection result may be perfectly genuine), but it is a fact a
2254
+ // caller should not lose.
2255
+ return scavengeError !== undefined ? { ...result, scavengeError } : result;
2256
+ }