@dzhechkov/harness-core 0.8.35 → 0.8.37

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 (135) hide show
  1. package/.dz-manifest.json +224 -104
  2. package/README.md +335 -10
  3. package/dist/agentdb-index.d.ts +87 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +416 -57
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +57 -1
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +450 -52
  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/codex-rollouts.d.ts +118 -0
  19. package/dist/codex-rollouts.d.ts.map +1 -0
  20. package/dist/codex-rollouts.js +297 -0
  21. package/dist/codex-rollouts.js.map +1 -0
  22. package/dist/cost-ledger.d.ts +56 -4
  23. package/dist/cost-ledger.d.ts.map +1 -1
  24. package/dist/cost-ledger.js +176 -20
  25. package/dist/cost-ledger.js.map +1 -1
  26. package/dist/cross-family-control.d.ts +345 -0
  27. package/dist/cross-family-control.d.ts.map +1 -0
  28. package/dist/cross-family-control.js +802 -0
  29. package/dist/cross-family-control.js.map +1 -0
  30. package/dist/debt-ratchet.d.ts +53 -0
  31. package/dist/debt-ratchet.d.ts.map +1 -0
  32. package/dist/debt-ratchet.js +107 -0
  33. package/dist/debt-ratchet.js.map +1 -0
  34. package/dist/embedding-config.d.ts +42 -0
  35. package/dist/embedding-config.d.ts.map +1 -1
  36. package/dist/embedding-config.js +106 -10
  37. package/dist/embedding-config.js.map +1 -1
  38. package/dist/feature-adr-checkpoints.d.ts +6 -0
  39. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  40. package/dist/feature-adr-checkpoints.js +29 -0
  41. package/dist/feature-adr-checkpoints.js.map +1 -1
  42. package/dist/feature-adr-decision-recall.d.ts +2 -2
  43. package/dist/feature-adr-decision-recall.d.ts.map +1 -1
  44. package/dist/feature-adr-decision-recall.js +5 -3
  45. package/dist/feature-adr-decision-recall.js.map +1 -1
  46. package/dist/feature-adr-envelope.d.ts +96 -0
  47. package/dist/feature-adr-envelope.d.ts.map +1 -0
  48. package/dist/feature-adr-envelope.js +183 -0
  49. package/dist/feature-adr-envelope.js.map +1 -0
  50. package/dist/feature-adr-routing.d.ts +64 -0
  51. package/dist/feature-adr-routing.d.ts.map +1 -1
  52. package/dist/feature-adr-routing.js +122 -2
  53. package/dist/feature-adr-routing.js.map +1 -1
  54. package/dist/feature-adr-stage-canon.d.ts +79 -0
  55. package/dist/feature-adr-stage-canon.d.ts.map +1 -0
  56. package/dist/feature-adr-stage-canon.js +117 -0
  57. package/dist/feature-adr-stage-canon.js.map +1 -0
  58. package/dist/index.d.ts +23 -12
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +15 -7
  61. package/dist/index.js.map +1 -1
  62. package/dist/loop-blobs.generated.js +4 -4
  63. package/dist/loop-blobs.generated.js.map +1 -1
  64. package/dist/mutation-gate.d.ts +51 -0
  65. package/dist/mutation-gate.d.ts.map +1 -1
  66. package/dist/mutation-gate.js +295 -0
  67. package/dist/mutation-gate.js.map +1 -1
  68. package/dist/operations.d.ts +1 -0
  69. package/dist/operations.d.ts.map +1 -1
  70. package/dist/operations.js +18 -2
  71. package/dist/operations.js.map +1 -1
  72. package/dist/publish.d.ts +59 -7
  73. package/dist/publish.d.ts.map +1 -1
  74. package/dist/publish.js +205 -32
  75. package/dist/publish.js.map +1 -1
  76. package/dist/qe-bridge.d.ts.map +1 -1
  77. package/dist/qe-bridge.js +4 -2
  78. package/dist/qe-bridge.js.map +1 -1
  79. package/dist/qe-findings.d.ts +107 -0
  80. package/dist/qe-findings.d.ts.map +1 -0
  81. package/dist/qe-findings.js +417 -0
  82. package/dist/qe-findings.js.map +1 -0
  83. package/dist/recap.d.ts +1 -1
  84. package/dist/recap.d.ts.map +1 -1
  85. package/dist/recap.js +4 -2
  86. package/dist/recap.js.map +1 -1
  87. package/dist/release-line.d.ts +16 -0
  88. package/dist/release-line.d.ts.map +1 -1
  89. package/dist/release-line.js +31 -0
  90. package/dist/release-line.js.map +1 -1
  91. package/dist/round.d.ts +74 -1
  92. package/dist/round.d.ts.map +1 -1
  93. package/dist/round.js +112 -4
  94. package/dist/round.js.map +1 -1
  95. package/dist/run-records.d.ts +60 -0
  96. package/dist/run-records.d.ts.map +1 -1
  97. package/dist/run-records.js +244 -2
  98. package/dist/run-records.js.map +1 -1
  99. package/dist/score.d.ts +44 -1
  100. package/dist/score.d.ts.map +1 -1
  101. package/dist/score.js +78 -5
  102. package/dist/score.js.map +1 -1
  103. package/dist/vector-tier.d.ts +34 -3
  104. package/dist/vector-tier.d.ts.map +1 -1
  105. package/dist/vector-tier.js +105 -14
  106. package/dist/vector-tier.js.map +1 -1
  107. package/package.json +2 -2
  108. package/sbom.json +403 -103
  109. package/src/agentdb-index.ts +423 -60
  110. package/src/apply-leg.ts +469 -50
  111. package/src/codex-hooks-assets.ts +67 -5
  112. package/src/codex-hooks.ts +13 -1
  113. package/src/codex-rollouts.ts +374 -0
  114. package/src/cost-ledger.ts +232 -24
  115. package/src/cross-family-control.ts +960 -0
  116. package/src/debt-ratchet.ts +143 -0
  117. package/src/embedding-config.ts +131 -10
  118. package/src/feature-adr-checkpoints.ts +29 -0
  119. package/src/feature-adr-decision-recall.ts +6 -4
  120. package/src/feature-adr-envelope.ts +242 -0
  121. package/src/feature-adr-routing.ts +139 -2
  122. package/src/feature-adr-stage-canon.ts +141 -0
  123. package/src/index.ts +66 -7
  124. package/src/loop-blobs.generated.ts +4 -4
  125. package/src/mutation-gate.ts +316 -0
  126. package/src/operations.ts +18 -3
  127. package/src/publish.ts +247 -30
  128. package/src/qe-bridge.ts +4 -2
  129. package/src/qe-findings.ts +463 -0
  130. package/src/recap.ts +10 -3
  131. package/src/release-line.ts +32 -0
  132. package/src/round.ts +165 -6
  133. package/src/run-records.ts +282 -2
  134. package/src/score.ts +115 -6
  135. package/src/vector-tier.ts +127 -14
package/dist/apply-leg.js CHANGED
@@ -32,7 +32,7 @@ import { connect as netConnect } from 'node:net';
32
32
  // (core-boundary.test.ts's ratchet only counts fs/child_process/https). `probeApplyLeg`'s temp probe
33
33
  // cwd reuses the existing top-level 'node:fs' import above (mkdtempSync/rmSync added to that SAME
34
34
  // import statement, not a new one) so the ratchet stays at its pinned files:63 imports:69.
35
- import { tmpdir } from 'node:os';
35
+ import { tmpdir, uptime } from 'node:os';
36
36
  import { join, resolve } from 'node:path';
37
37
  import { hookCommandsOf } from './managed-hooks.js';
38
38
  import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync } from './patterns.js';
@@ -193,8 +193,26 @@ export function probeRecallEngine(socketPath, timeoutMs = 1000) {
193
193
  * the cosine-fallback `scored` array are filtered to exclude `domain === 'apply-leg-probe'` unless
194
194
  * the request carried `probe: true` — a probe's own beacon still needs to reach ITS query, only a
195
195
  * REAL prompt must never see it.
196
+ *
197
+ * Bumped 10→11 (feature `embed-daemon-memory`, ADR-001 D1): the daemon now tries core's OWN cached
198
+ * embedder (`resolveAgentdbEmbedder`) first — one pipeline per process — building a private one only
199
+ * when core cannot hand one over; `ready` gained the ` embedder=<core-shared|own-fallback>` suffix
200
+ * (see `features/embed-daemon-memory/07_code_changes/change_manifest.md` for the full T3 note; this
201
+ * paragraph was missing from the bump history and is added here for the record, fix round 1).
202
+ *
203
+ * Bumped 11→12 (`embed-daemon-memory`, fix round 1 — F1/F2, Codex #1b/#3): (a) F1 — the own-fallback
204
+ * pipeline now reads the STORE's own dtype manifest before building (absent = fp32 legacy, a
205
+ * present-but-unrecognized value fails the request loudly instead of silently building fp32 against
206
+ * a q8 store); `ready`'s own-fallback branch gains ` dtype=<fp32|q8|error>`. (b) F2 — a core-shared
207
+ * STARTUP init failure no longer falls back to building an own pipeline (which risked a second live
208
+ * embedder once a later hybrid call succeeded): `embedderSource` stays `'core-shared'` and `embed`
209
+ * re-resolves `core.resolveAgentdbEmbedder` per request instead; `ready` names a startup failure
210
+ * inline as `core-shared (init failed: <msg>, will retry per request)`; `warmUpHybridEngine()` is
211
+ * skipped entirely in own-fallback mode. (c) `answerRecall`'s `embed(prompt)` call is now wrapped so
212
+ * either failure mode degrades to the SAME honest `cosine-fallback` reply shape as every other
213
+ * failure, never a bare protocol `{error}`.
196
214
  */
197
- export const APPLY_LEG_VERSION = 10;
215
+ export const APPLY_LEG_VERSION = 12;
198
216
  /**
199
217
  * Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
200
218
  * `writerVersionOf` (which floors an absent stamp at `0`), this returns `-1` for "no stamp at
@@ -1021,6 +1039,18 @@ function socketAlive(path) {
1021
1039
  });
1022
1040
  }
1023
1041
 
1042
+ // ADR-001 (embed-daemon-memory, D1/T3): better-sqlite3 is resolved INDEPENDENTLY of the embedder now
1043
+ // — the cosine-fallback leg always needs it to read the mirror, regardless of whether the embedder
1044
+ // itself comes from core (embedder=core-shared) or this daemon's own pipeline (embedder=own-fallback).
1045
+ // Splitting it out of the old resolveDeps() lets the daemon start on core-shared alone when only
1046
+ // better-sqlite3 (not transformers) is locally resolvable.
1047
+ function resolveDatabase() {
1048
+ const req = createRequire(join(PROJECT, 'package.json'));
1049
+ return req('better-sqlite3');
1050
+ }
1051
+
1052
+ // own-fallback ONLY (ADR-001 D1): resolves the transformers module for the daemon's OWN pipeline,
1053
+ // built only when core has no resolveAgentdbEmbedder to share (no core module, or it errored).
1024
1054
  function resolveDeps() {
1025
1055
  const req = createRequire(join(PROJECT, 'package.json'));
1026
1056
  // agentdb >= 3.0.0-alpha depends on '@huggingface/transformers' (the '@xenova/transformers'
@@ -1048,7 +1078,7 @@ function resolveDeps() {
1048
1078
  if (transformers === undefined) {
1049
1079
  throw new Error('neither @huggingface/transformers nor @xenova/transformers could be resolved (' + String((lastErr && lastErr.message) || lastErr) + ')');
1050
1080
  }
1051
- return { Database: req('better-sqlite3'), transformers };
1081
+ return { transformers };
1052
1082
  }
1053
1083
 
1054
1084
  const cos = (a, b) => {
@@ -1113,17 +1143,151 @@ async function main() {
1113
1143
 
1114
1144
  const started = Date.now();
1115
1145
  const model = configuredModel();
1116
- let deps;
1146
+
1147
+ // better-sqlite3 is required regardless of which embedder answers a request (ADR-001, T3) — the
1148
+ // cosine-fallback leg always reads the mirror through it.
1149
+ let Database;
1117
1150
  try {
1118
- deps = resolveDeps();
1151
+ Database = resolveDatabase();
1119
1152
  } catch (err) {
1120
1153
  log('deps unavailable — not starting:', err?.message ?? err);
1121
1154
  process.exit(0); // never a hard failure: the hook degrades to silence
1122
1155
  }
1123
1156
 
1124
- const { pipeline } = await import(deps.transformers);
1125
- const extractor = await pipeline('feature-extraction', model);
1126
- const embed = async (text) => Array.from((await extractor(text, { pooling: 'mean', normalize: true })).data);
1157
+ // ADR-001 D1 (embed-daemon-memory): the daemon SHARES core's single per-process embedder and
1158
+ // builds a private pipeline only when core cannot hand one over AT ALL — no core module, or a core
1159
+ // build too old to export \`resolveAgentdbEmbedder\` (F2, fix round 1, Codex #3). A STARTUP init
1160
+ // FAILURE (core exists, has the export, but the call itself errored — offline model cache, a
1161
+ // corrupted manifest) is NEVER treated as "core unavailable": own-fallback must not be built in
1162
+ // that case, because a LATER hybrid call could succeed and stand up a SECOND, independent pipeline
1163
+ // in the same process — the exact "two live embedders" defect the review named. Instead
1164
+ // \`embedderSource\` STAYS \`'core-shared'\` and \`embed\` re-resolves \`core.resolveAgentdbEmbedder\`
1165
+ // on EVERY call: core's own per-process cache (agentdb-index.ts) makes a repeat call after SUCCESS
1166
+ // effectively free, and evicts its own entry on failure — so a transient startup failure can heal
1167
+ // on a later request without this daemon ever building an embedder of its own.
1168
+ //
1169
+ // The shared embedder is still PROBED EAGERLY, before \`ready\`, for the same reason the own
1170
+ // pipeline always was: the cosine-fallback leg must answer inside the hook budget on the FIRST
1171
+ // request too (parity AC-2: hybrid delayed by 3000 ms, reply < 2000 ms). MEASURED 2026-09-16
1172
+ // (change_manifest.md, deviation B): a LAZY resolve put the cold pipeline init (2000-3600 ms) on
1173
+ // the first fallback reply — 4/5 runs at 2063-2146 ms. The probe's OUTCOME only decides the
1174
+ // \`ready\`-log wording now (F2) — it never gates \`embedderSource\` or builds an own-fallback
1175
+ // pipeline. The starved-budget parity test that used to lean on a cold engine now injects
1176
+ // DZ_EMBED_HYBRID_DELAY_MS itself, so its premise holds by construction, not by cold timing.
1177
+ let embed;
1178
+ let embedderSource;
1179
+ let embedderReadyDetail = '';
1180
+ let ownFallbackDtypeLabel;
1181
+ const startupCore = await loadCoreModule();
1182
+ const coreHasSharedEmbedder = startupCore !== undefined && typeof startupCore.resolveAgentdbEmbedder === 'function';
1183
+ if (coreHasSharedEmbedder) {
1184
+ embedderSource = 'core-shared';
1185
+ embed = async (text) => {
1186
+ const shared = await startupCore.resolveAgentdbEmbedder(PROJECT);
1187
+ if (!shared || typeof shared.embed !== 'function' || 'error' in shared) {
1188
+ throw new Error((shared && shared.error) || 'resolveAgentdbEmbedder returned no embed()');
1189
+ }
1190
+ return shared.embed(text);
1191
+ };
1192
+ let startupProbe;
1193
+ try {
1194
+ startupProbe = await startupCore.resolveAgentdbEmbedder(PROJECT);
1195
+ } catch (err) {
1196
+ startupProbe = { error: err?.message ?? String(err) };
1197
+ }
1198
+ if (!startupProbe || typeof startupProbe.embed !== 'function' || 'error' in startupProbe) {
1199
+ const msg = (startupProbe && startupProbe.error) || 'resolveAgentdbEmbedder returned no embed()';
1200
+ log('core-shared embedder init failed at startup, will retry per request:', msg);
1201
+ embedderReadyDetail = \` (init failed: \${msg}, will retry per request)\`;
1202
+ }
1203
+ }
1204
+ if (embed === undefined) {
1205
+ // own-fallback: ONLY when core has no resolveAgentdbEmbedder to share at all (no core module, or
1206
+ // a fake/old core, as in test fixtures) — never as a reaction to a startup init error (above).
1207
+ let deps;
1208
+ try {
1209
+ deps = resolveDeps();
1210
+ } catch (err) {
1211
+ log('deps unavailable — not starting:', err?.message ?? err);
1212
+ process.exit(0); // never a hard failure: the hook degrades to silence
1213
+ }
1214
+ // F1 (fix round 1, Codex #1b): the daemon has no core to ask, so it reads the STORE's own dtype
1215
+ // manifest directly — an own pipeline built blindly at fp32 would silently compare vectors from
1216
+ // two different spaces against a q8 store. Absent manifest = fp32 (legacy — matches every store
1217
+ // predating this feature, same discipline as embedding-config.ts's readEmbedManifest). A manifest
1218
+ // that IS present but names neither known dtype is NEVER folded into that same fp32 case — this
1219
+ // daemon fails loudly for that request (embed() throws below, caught honestly by answerRecall)
1220
+ // rather than silently mixing dtype spaces.
1221
+ const manifestPath = join(PROJECT, '.dz', 'agentdb.db.embed-manifest.json');
1222
+ let ownDtype = 'fp32';
1223
+ let ownDtypeError;
1224
+ let manifestIsFile = false;
1225
+ try {
1226
+ manifestIsFile = statSync(manifestPath).isFile(); // a non-file at the sidecar path is "absent", as in core
1227
+ } catch {
1228
+ manifestIsFile = false;
1229
+ }
1230
+ if (manifestIsFile) {
1231
+ try {
1232
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'))?.dtype;
1233
+ if (raw === 'fp32' || raw === 'q8') {
1234
+ ownDtype = raw;
1235
+ } else if (raw !== undefined) {
1236
+ ownDtypeError = String(raw);
1237
+ }
1238
+ } catch (err) {
1239
+ // Lead delta after Codex r2 (HIGH): a manifest that EXISTS but cannot be parsed is NOT
1240
+ // "absent" — refusing beats guessing fp32 over a q8 store (same rule as core's readError).
1241
+ ownDtypeError = 'unreadable manifest: ' + (err?.message ?? String(err));
1242
+ }
1243
+ }
1244
+ embedderSource = 'own-fallback';
1245
+ if (ownDtypeError !== undefined) {
1246
+ log('manifest dtype unknown:', ownDtypeError);
1247
+ ownFallbackDtypeLabel = 'error';
1248
+ const dtypeErrMsg = \`manifest dtype unknown: \${ownDtypeError}\`;
1249
+ embed = async () => { throw new Error(dtypeErrMsg); };
1250
+ } else {
1251
+ ownFallbackDtypeLabel = ownDtype;
1252
+ const { pipeline } = await import(deps.transformers);
1253
+ const extractor = await pipeline('feature-extraction', model, ownDtype === 'q8' ? { dtype: 'q8' } : {});
1254
+ embed = async (text) => Array.from((await extractor(text, { pooling: 'mean', normalize: true })).data);
1255
+ }
1256
+ }
1257
+
1258
+ // AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
1259
+ // ~2-3.6 s, warm ~1 ms, MEASURED, see the manifest's T8/AM-1 discussion) — OFF the request path,
1260
+ // so the first REAL \`op: recall\` is not the one that pays the cold init. Fired fire-and-forget as
1261
+ // early as main() can (moved up from right-before-listen, T3/embed-daemon-memory: every ms of
1262
+ // extra head start matters against a multi-second cold cost — see change_manifest.md), never
1263
+ // awaited by startup: this is a best-effort head start, not a guarantee — a request landing in
1264
+ // the window before it completes still pays the (possibly-partial, since it JOINS the same
1265
+ // in-flight resolveAgentdbEmbedder promise, D1) cold cost, and a warm-up failure (no core module,
1266
+ // engine error) is silently swallowed — never-block applies to startup exactly as it does to a
1267
+ // request. Measured: the slowest cold resolveAgentdbEmbedder init observed in this environment
1268
+ // was 3653 ms (T8 log, 2026-09-14) — 10 s leaves a wide margin without risking an unbounded
1269
+ // warm-up hang.
1270
+ const WARMUP_TIMEOUT_MS = 10_000;
1271
+ async function warmUpHybridEngine() {
1272
+ const core = await loadCoreModule();
1273
+ if (core === undefined) return;
1274
+ const guard = new Promise((resolve) => {
1275
+ const t = setTimeout(resolve, WARMUP_TIMEOUT_MS);
1276
+ t.unref?.();
1277
+ });
1278
+ // An empty-string query still exercises the FULL semantic leg (embed + engine.search), which is
1279
+ // exactly what needs warming; recallHybrid degrades any error inside it honestly, so nothing
1280
+ // here needs its own try/catch beyond the outer .catch(() => {}) at the call site below.
1281
+ await Promise.race([core.recallHybrid(PROJECT, '', { limit: 1, mode: 'hook', deferExposures: true }), guard]);
1282
+ }
1283
+ // Fire-and-forget, never awaited — main() proceeds immediately regardless of warm-up outcome.
1284
+ // F2 (fix round 1, Codex #3): warm-up specifically primes the HYBRID leg's own use of
1285
+ // resolveAgentdbEmbedder — pointless when this daemon has no core embedder to share (own-fallback
1286
+ // has no core module, or a fake/old core in tests) AND risks standing up a SECOND, unrelated
1287
+ // pipeline via whatever recallHybrid does internally in that case. Skipped entirely in own-fallback.
1288
+ if (embedderSource === 'core-shared') {
1289
+ warmUpHybridEngine().catch(() => {});
1290
+ }
1127
1291
 
1128
1292
  // READ-ONLY. This process must never be the writer that tears the file for a concurrent reader.
1129
1293
  const dbPath = join(PROJECT, '.dz', 'agentdb.db');
@@ -1131,7 +1295,7 @@ async function main() {
1131
1295
 
1132
1296
  function loadPatterns() {
1133
1297
  if (!existsSync(dbPath)) return [];
1134
- const db = new deps.Database(dbPath, { readonly: true, fileMustExist: true });
1298
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
1135
1299
  try {
1136
1300
  const ph = DZ_TASK_TYPES.map(() => '?').join(',');
1137
1301
  // NOTE: the vector mirror carries no \`domain\` — that column lives in the lexical store. An
@@ -1243,36 +1407,22 @@ async function main() {
1243
1407
  patternsAt = Date.now();
1244
1408
  }
1245
1409
  if (patterns.length === 0) return { hits: [], engine: 'cosine-fallback', reason: hybrid.reason };
1246
- const qv = await embed(prompt);
1410
+ // F1/F2 (fix round 1): \`embed()\` can now THROW honestly (own-fallback with an unrecognized
1411
+ // manifest dtype, F1; core-shared re-resolution failing on this exact request, F2) — caught here
1412
+ // so it degrades to the SAME honest cosine-fallback shape every other failure gets, never a bare
1413
+ // protocol {error} reply reaching the socket handler's outer catch (the AM-3 defect class).
1414
+ let qv;
1415
+ try {
1416
+ qv = await embed(prompt);
1417
+ } catch (err) {
1418
+ return { hits: [], engine: 'cosine-fallback', reason: \`embedder unavailable: \${err?.message ?? err}\` };
1419
+ }
1247
1420
  const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), domain: p.domain, ...(p.quarantined ? { quarantined: true } : {}) }));
1248
1421
  scored.sort((a, b) => b.score - a.score);
1249
1422
  // AM-3: same domain exclusion as the hybrid leg, applied before slicing for the same reason.
1250
1423
  return { hits: filterProbeHits(scored, probe).slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
1251
1424
  }
1252
1425
 
1253
- // AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
1254
- // ~2-3.6 s, warm ~1 ms, MEASURED, see the manifest's T8/AM-1 discussion) — OFF the request path,
1255
- // so the first REAL \`op: recall\` is not the one that pays the cold init. Fired fire-and-forget
1256
- // right before \`listen()\` below, never awaited by startup: this is a best-effort head start, not
1257
- // a guarantee — a request landing in the few-hundred-ms window before it completes still pays the
1258
- // cold cost exactly as before this amendment, and a warm-up failure (no core module, engine
1259
- // error) is silently swallowed — never-block applies to startup exactly as it does to a request.
1260
- // Measured: the slowest cold resolveAgentdbEmbedder init observed in this environment was 3653 ms
1261
- // (T8 log, 2026-09-14) — 10 s leaves a wide margin without risking an unbounded warm-up hang.
1262
- const WARMUP_TIMEOUT_MS = 10_000;
1263
- async function warmUpHybridEngine() {
1264
- const core = await loadCoreModule();
1265
- if (core === undefined) return;
1266
- const guard = new Promise((resolve) => {
1267
- const t = setTimeout(resolve, WARMUP_TIMEOUT_MS);
1268
- t.unref?.();
1269
- });
1270
- // An empty-string query still exercises the FULL semantic leg (embed + engine.search), which is
1271
- // exactly what needs warming; recallHybrid degrades any error inside it honestly, so nothing
1272
- // here needs its own try/catch beyond the outer .catch(() => {}) at the call site below.
1273
- await Promise.race([core.recallHybrid(PROJECT, '', { limit: 1, mode: 'hook', deferExposures: true }), guard]);
1274
- }
1275
-
1276
1426
  let idleTimer;
1277
1427
  let lastActivityAt = Date.now();
1278
1428
  const touch = () => {
@@ -1347,9 +1497,6 @@ async function main() {
1347
1497
 
1348
1498
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => shutdown(0));
1349
1499
 
1350
- // AM-1: fire-and-forget, never awaited — bind proceeds immediately regardless of warm-up outcome.
1351
- warmUpHybridEngine().catch(() => {});
1352
-
1353
1500
  // FR-3 ("absence of a receipt is not success"): \`ready\` is printed ONLY after \`listen\`'s callback
1354
1501
  // AND a fresh \`existsSync(SOCKET)\` both confirm the socket file is actually on disk — a caller
1355
1502
  // that greps stderr for "ready" must never see it for a socket that silently failed to bind.
@@ -1391,7 +1538,11 @@ async function main() {
1391
1538
  return bindFailed(\`could not publish socket pointer: \${err?.message ?? err}\`);
1392
1539
  }
1393
1540
  }
1394
- log(\`ready: \${patterns.length} pattern vectors, model \${model}, socket \${SOCKET}\`);
1541
+ // F1/F2 (fix round 1): own-fallback names ITS resolved dtype (\`dtype=<fp32|q8|error>\`, F1); a
1542
+ // core-shared daemon that failed its startup probe names that too, inline (\`(init failed: …,
1543
+ // will retry per request)\`, F2) — both make the honest state observable from \`ready\` alone.
1544
+ const embedderReadyLabel = embedderSource === 'own-fallback' ? \`\${embedderSource} dtype=\${ownFallbackDtypeLabel}\` : \`\${embedderSource}\${embedderReadyDetail}\`;
1545
+ log(\`ready: \${patterns.length} pattern vectors, model \${model}, socket \${SOCKET} embedder=\${embedderReadyLabel}\`);
1395
1546
  touch();
1396
1547
  });
1397
1548
  server.on('error', (err) => bindFailed(err?.message ?? String(err)));
@@ -1697,6 +1848,215 @@ const PROBE_PROMPT_WORDS = 'apply leg live probe';
1697
1848
  /** Doctor/parity probes share ONE domain tag so a leaked beacon (a failed removal) is trivially
1698
1849
  * findable and excludable — never `dz-teach`/`general`, which would blend it into real lessons. */
1699
1850
  const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
1851
+ /**
1852
+ * FR-3 (feature `apply-leg-daemon-hygiene`): the scavenger that ran at the top of every probe used
1853
+ * to delete EVERY beacon-domain record unconditionally — safe against a probe killed mid-flight
1854
+ * (Codex round-2's own reason for the scavenger existing at all), but WRONG the moment two probes
1855
+ * from two DIFFERENT sessions can be live against the SAME store at once: the second probe's
1856
+ * scavenge deletes the first probe's still-in-flight beacon, and the first probe then reports a
1857
+ * false `ok:false` (its own hook query returns nothing, because the lesson it was about to match
1858
+ * against is already gone) — a false-red `dz doctor`/`dz parity` parity check with no defect behind
1859
+ * it. The fix: tag every beacon with its OWNER (`probe-owner=<pid>:<startedMs>`, embedded in the
1860
+ * pattern TEXT so it survives a round-trip through any store tier without a schema change — NFR-1
1861
+ * forbids a new column) and scavenge ONLY a beacon whose owner is provably gone: the pid no longer
1862
+ * answers `process.kill(pid, 0)`, OR the beacon has outlived its TTL (see {@link
1863
+ * scavengeStaleProbeBeacons}'s own `ttlMs` parameter).
1864
+ */
1865
+ const PROBE_OWNER_TAG_PREFIX = 'probe-owner=';
1866
+ /** Fix round 1 (HIGH-6 residual): a second, independent tag alongside `probe-owner=` — the WRITING
1867
+ * process's own OS-level start time (epoch ms, from `/proc/<pid>/stat`), so the scavenger can tell a
1868
+ * PID-REUSE case (the recorded pid is technically "alive" per `process.kill(pid,0)`, but the LIVE
1869
+ * process at that pid started at a different time than the one that wrote the beacon) apart from
1870
+ * the genuine same-process case. Absent whenever the write-time `/proc` read fails (non-Linux,
1871
+ * permission) — the scavenger then falls back to the plain alive+TTL check alone, unchanged. */
1872
+ const PROC_START_TAG_PREFIX = 'proc-start=';
1873
+ /** Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry, in epoch ms, written by the
1874
+ * probe that owns it. The pre-delta scavenger applied ITS OWN `ttlMs` to SOMEONE ELSE'S beacon, so a
1875
+ * default-budget probe (ttl 60 s) deleted the live beacon of a widened-budget probe (ttl 360 s) after
1876
+ * 60 s — the very false-red this hardening exists to prevent, one level up. A beacon now states when
1877
+ * IT expires; the scavenger's own `ttlMs` is only the fallback for a beacon written before this tag
1878
+ * existed. */
1879
+ const PROBE_EXPIRES_TAG_PREFIX = 'probe-expires=';
1880
+ /**
1881
+ * Fix round 1 (HIGH-6): the pre-fix-round TTL was a flat 60 000 ms, independent of the probe's own
1882
+ * `timeoutMs` — a caller that legitimately widens `timeoutMs` past that (a widened, suspended, or
1883
+ * heavily loaded probe) could have its OWN still-in-flight beacon scavenged by a concurrent probe
1884
+ * before it ever replies. The TTL is now DERIVED from the probe's own `timeoutMs`
1885
+ * (`max(timeoutMs * 3, 60_000)`, see {@link probeApplyLeg}'s call site) so a widened timeout widens
1886
+ * its own protection window too; the 60 000 ms floor keeps the pre-fix-round generous margin for the
1887
+ * default (unwidened) case. Exported as a named constant only for the floor value — the ACTUAL TTL
1888
+ * used by a given probe is always `ttlMs`, computed at the call site, never this constant alone.
1889
+ */
1890
+ const PROBE_BEACON_TTL_FLOOR_MS = 60_000;
1891
+ function formatProbeOwner(pid, startedMs) {
1892
+ return `${PROBE_OWNER_TAG_PREFIX}${pid}:${startedMs}`;
1893
+ }
1894
+ /** Parses the `probe-owner=<pid>:<startedMs>` tag out of a beacon's pattern text. `undefined` for
1895
+ * any beacon predating this tag (an older deployed core wrote it) — treated by the scavenger as
1896
+ * ownerless and therefore always safe to remove (the pre-FR-3 behavior for exactly that case). */
1897
+ function parseProbeOwner(patternText) {
1898
+ const idx = patternText.indexOf(PROBE_OWNER_TAG_PREFIX);
1899
+ if (idx === -1)
1900
+ return undefined;
1901
+ const match = /probe-owner=(\d+):(\d+)/u.exec(patternText.slice(idx));
1902
+ if (match?.[1] === undefined || match[2] === undefined)
1903
+ return undefined;
1904
+ return { pid: Number(match[1]), startedMs: Number(match[2]) };
1905
+ }
1906
+ function formatProcStart(procStartedAtMs) {
1907
+ return ` ${PROC_START_TAG_PREFIX}${procStartedAtMs}`;
1908
+ }
1909
+ function formatProbeExpires(expiresAtMs) {
1910
+ return ` ${PROBE_EXPIRES_TAG_PREFIX}${expiresAtMs}`;
1911
+ }
1912
+ /** Parses the `probe-expires=<epochMs>` tag — `undefined` for a beacon written before the tag
1913
+ * existed, which is exactly when the scavenger falls back to its own `ttlMs`. */
1914
+ function parseProbeExpires(patternText) {
1915
+ const idx = patternText.indexOf(PROBE_EXPIRES_TAG_PREFIX);
1916
+ if (idx === -1)
1917
+ return undefined;
1918
+ const match = /probe-expires=(\d+)/u.exec(patternText.slice(idx));
1919
+ if (match?.[1] === undefined)
1920
+ return undefined;
1921
+ return Number(match[1]);
1922
+ }
1923
+ /** Parses the `proc-start=<ticks>` tag — `undefined` when absent (pre-fix-round beacon, or the
1924
+ * write-time `/proc` read failed). */
1925
+ function parseProcStart(patternText) {
1926
+ const idx = patternText.indexOf(PROC_START_TAG_PREFIX);
1927
+ if (idx === -1)
1928
+ return undefined;
1929
+ const match = /proc-start=(\d+)/u.exec(patternText.slice(idx));
1930
+ if (match?.[1] === undefined)
1931
+ return undefined;
1932
+ return Number(match[1]);
1933
+ }
1934
+ /** True when `pid` answers a liveness signal — `process.kill(pid, 0)` sends no actual signal, it
1935
+ * only probes whether the OS still has a process at that pid (ESRCH ⇒ dead). */
1936
+ function isPidAlive(pid) {
1937
+ try {
1938
+ process.kill(pid, 0);
1939
+ return true;
1940
+ }
1941
+ catch (err) {
1942
+ return err.code !== 'ESRCH';
1943
+ }
1944
+ }
1945
+ /**
1946
+ * Fix round 1 (HIGH-6 residual, Linux-only, best-effort), tightened by the lead after Codex round 2
1947
+ * (MEDIUM-6): a process's OWN `starttime` from `/proc/<pid>/stat` — field 22 overall, found by
1948
+ * skipping past the LAST `)` so a `comm` containing spaces or parens never misaligns the split —
1949
+ * returned as RAW TICKS SINCE BOOT, the unit the kernel reports. No wall clock is consulted and no
1950
+ * CLK_TCK conversion is performed, so a stepped wall clock can no longer make the same live process
1951
+ * look like a different one. `undefined` on ANY read/parse failure (non-Linux, permission, the
1952
+ * process exiting mid-read) — the caller MUST treat that as "cannot prove", never as a pass in
1953
+ * either direction.
1954
+ */
1955
+ function pidStartedAtMsFromProcStat(pid) {
1956
+ try {
1957
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
1958
+ const afterComm = stat.slice(stat.lastIndexOf(')') + 1).trim();
1959
+ const fields = afterComm.split(/\s+/u);
1960
+ // Overall field 22 (`starttime`) = fields[22 - 3] here, since fields[] starts at overall field 3
1961
+ // (state) once `pid (comm)` (fields 1-2) has been stripped above.
1962
+ const starttimeTicks = Number(fields[19]);
1963
+ if (!Number.isFinite(starttimeTicks))
1964
+ return undefined;
1965
+ // Lead delta after Codex round 2 (MEDIUM-6): return the RAW ticks-since-boot, never an epoch
1966
+ // derived from `Date.now() - uptime()`. The derived epoch moves when the WALL CLOCK is stepped,
1967
+ // so the same still-running process appeared to have "started at a different time" and its LIVE
1968
+ // beacon was deleted as a pid-reuse. Ticks since boot are monotonic within a boot and identical
1969
+ // for the same process on every read; across a reboot the recorded pid is dead anyway, which the
1970
+ // liveness check catches first — WITHDRAWN by the lead after Codex round 4: that sentence was
1971
+ // wrong, because across a reboot the pid may ALREADY have been reused and can collide on start
1972
+ // ticks too; the honest scope is the NAMED LIMIT stated below. This also removes the CLK_TCK assumption entirely — no conversion
1973
+ // to milliseconds happens at all, the two values are compared in their own unit.
1974
+ // NAMED LIMIT (lead delta after Codex round 3, MEDIUM): identity holds WITHIN one boot. Across a
1975
+ // reboot both pid allocation and ticks-since-boot restart, so a beacon that somehow persisted
1976
+ // could in principle collide with a new process at the same pid and the same tick. A boot id
1977
+ // would close it; the honest scope today is "within one boot", and a reboot also means the
1978
+ // beacon's own TTL has almost certainly passed, which the TTL branch catches first.
1979
+ return starttimeTicks;
1980
+ }
1981
+ catch {
1982
+ return undefined;
1983
+ }
1984
+ }
1985
+ /** Fix round 1 (HIGH-6 residual): `process.kill(pid,0)` proves SOME process occupies `pid`, never
1986
+ * that it is the SAME process that wrote the beacon — PID reuse defeats the plain alive check named
1987
+ * as a residual limit in the review. Compares the LIVE process's own start time (from `/proc/<pid>/
1988
+ * stat`) against `recordedStartedMs` (the beacon's own `proc-start=` tag). A mismatch beyond {@link
1989
+ * PID_START_TOLERANCE_MS} means the pid was reused by an unrelated process — the true owner is
1990
+ * confirmed gone. `undefined` (stat unreadable) means "cannot prove either way" — the lead's decision
1991
+ * (fix round 1, item 6) is explicit: that MUST read as "do not remove" wherever it is consumed, never
1992
+ * as a pass. */
1993
+ function isSameProcessInstance(pid, recordedStartTicks) {
1994
+ const actualStartTicks = pidStartedAtMsFromProcStat(pid);
1995
+ if (actualStartTicks === undefined)
1996
+ return undefined;
1997
+ // Lead delta after Codex round 2 (MEDIUM-6): EXACT equality on ticks-since-boot. The old
1998
+ // ±5 000 ms tolerance existed only to absorb the CLK_TCK guess in the epoch conversion; with the
1999
+ // raw kernel value there is nothing to absorb, and a tolerance would re-admit the very pid-reuse
2000
+ // case it was meant to exclude (a reused pid started within the tolerance read as "same process").
2001
+ return actualStartTicks === recordedStartTicks;
2002
+ }
2003
+ /**
2004
+ * FR-3/FR-4: remove only the STALE beacon-domain records in `root`'s store — owner dead
2005
+ * (`process.kill(pid, 0)` ⇒ ESRCH), older than `ttlMs`, OR (fix round 1, HIGH-6 residual) alive AND
2006
+ * within `ttlMs` but the live pid's OWN `/proc/<pid>/stat` start time no longer matches the beacon's
2007
+ * `proc-start=` tag — a confirmed PID-reuse case, where the recorded owner is provably gone even
2008
+ * though `pid` itself answers. An UNREADABLE stat at scavenge time never counts as reuse evidence —
2009
+ * it falls back to the plain alive+TTL verdict, per the lead's "cannot prove ⇒ do not remove"
2010
+ * decision. A beacon whose owner is alive, within TTL, and (when provable) confirmed the SAME
2011
+ * process is left untouched, even though it belongs to a different probe. An ownerless (pre-FR-3)
2012
+ * beacon is always treated as stale. `ttlMs` defaults to {@link PROBE_BEACON_TTL_FLOOR_MS} for a
2013
+ * caller that does not derive one (`apply-leg-beacon-owner.test.ts`'s own fixtures); `probeApplyLeg`
2014
+ * always passes its own derived value. Exported so `apply-leg-beacon-owner.test.ts` can exercise the
2015
+ * property directly, without needing a live hook/daemon (FR-4's red-first case needs only a store
2016
+ * and an injectable remover, never a real probe round-trip).
2017
+ */
2018
+ export function scavengeStaleProbeBeacons(root, removeBeacon, ttlMs = PROBE_BEACON_TTL_FLOOR_MS) {
2019
+ try {
2020
+ const beacons = loadStorePatternsSync(root).filter((p) => p.domain === PROBE_BEACON_DOMAIN);
2021
+ const staleIds = [];
2022
+ for (const beacon of beacons) {
2023
+ const owner = parseProbeOwner(beacon.pattern);
2024
+ if (owner === undefined) {
2025
+ staleIds.push(beacon.dzId ?? patternRecordId(beacon));
2026
+ continue;
2027
+ }
2028
+ const alive = isPidAlive(owner.pid);
2029
+ // Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry wins over this scavenger's
2030
+ // `ttlMs`, which belongs to a DIFFERENT probe and knows nothing of this owner's budget.
2031
+ const ownExpiresAtMs = parseProbeExpires(beacon.pattern);
2032
+ const withinTtl = ownExpiresAtMs !== undefined
2033
+ ? Date.now() <= ownExpiresAtMs
2034
+ : Date.now() - owner.startedMs <= ttlMs;
2035
+ if (!alive || !withinTtl) {
2036
+ staleIds.push(beacon.dzId ?? patternRecordId(beacon));
2037
+ continue;
2038
+ }
2039
+ // alive AND within TTL — the plain check says "keep", but confirm it is the SAME process, not
2040
+ // a reused pid, whenever the beacon carries the (best-effort) proc-start tag.
2041
+ const procStart = parseProcStart(beacon.pattern);
2042
+ if (procStart !== undefined) {
2043
+ const same = isSameProcessInstance(owner.pid, procStart);
2044
+ if (same === false)
2045
+ staleIds.push(beacon.dzId ?? patternRecordId(beacon)); // confirmed reuse
2046
+ // same === true, or same === undefined (unreadable ⇒ cannot prove ⇒ do not remove): keep.
2047
+ }
2048
+ }
2049
+ if (staleIds.length === 0)
2050
+ return { ok: true, removed: 0 };
2051
+ const result = removeBeacon(root, new Set(staleIds));
2052
+ if (result.error !== undefined)
2053
+ return { ok: false, error: result.error };
2054
+ return { ok: true, removed: result.removed };
2055
+ }
2056
+ catch (err) {
2057
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2058
+ }
2059
+ }
1700
2060
  /**
1701
2061
  * Live, end-to-end proof that the apply leg actually injects — ADR-001 Decision 1. `applyLegStatus`
1702
2062
  * only proves FILES exist and are STRUCTURALLY wired (issue #2's whole defect: four green checks,
@@ -1729,6 +2089,11 @@ const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
1729
2089
  export async function probeApplyLeg(root, opts = {}) {
1730
2090
  const started = Date.now();
1731
2091
  const elapsed = () => Date.now() - started;
2092
+ // Fix round 1 (HIGH-6): computed HERE, before the scavenger runs, so the scavenge TTL can be
2093
+ // derived from THIS probe's own budget rather than a flat constant blind to a caller-widened
2094
+ // timeout — see the `ttlMs` computation below, right before the beacon that carries its implicit
2095
+ // promise is written.
2096
+ const timeoutMs = opts.timeoutMs ?? 8000;
1732
2097
  const status = applyLegStatus(root);
1733
2098
  if (!status.installed) {
1734
2099
  return { ok: false, reason: 'apply-leg not installed', elapsedMs: elapsed() };
@@ -1751,8 +2116,32 @@ export async function probeApplyLeg(root, opts = {}) {
1751
2116
  // pattern's own text can produce it. `ok: true` therefore requires the SECRET, never the query.
1752
2117
  const queryToken = `dzapplylegquery${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
1753
2118
  const secretToken = `dzapplylegsecret${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
2119
+ // FR-3: the owner tag rides the SAME pattern text as the secret — a beacon's owner is knowable
2120
+ // from its store record alone, no side channel, no schema change (NFR-1).
2121
+ const ownerTag = formatProbeOwner(process.pid, started);
2122
+ // Fix round 1 (HIGH-6): TTL derives from THIS probe's own timeoutMs — a caller that widens
2123
+ // timeoutMs (this repo's own live tests widen it to 15_000 ms) widens its own protection window
2124
+ // too, rather than staying pinned to a flat constant blind to that widening. Floored at the
2125
+ // pre-fix-round 60_000 ms so the default (unwidened) case keeps its original generous margin.
2126
+ // Asserted, not merely trusted — an unreasoned future edit to the formula must fail loudly right
2127
+ // here, where the beacon carrying this TTL's implicit promise is about to be written, rather than
2128
+ // silently reintroducing the false-red-under-concurrency defect this closes.
2129
+ const ttlMs = Math.max(timeoutMs * 3, PROBE_BEACON_TTL_FLOOR_MS);
2130
+ if (!(timeoutMs < ttlMs)) {
2131
+ throw new Error(`invariant violated: timeoutMs (${String(timeoutMs)}) must be < ttlMs (${String(ttlMs)}) — scavenger TTL formula regressed`);
2132
+ }
2133
+ // Fix round 1 (HIGH-6 residual, best-effort): the writing process's OWN OS-level start time,
2134
+ // captured NOW so a later scavenger can tell a genuinely-still-alive owner apart from a DIFFERENT
2135
+ // process that merely reused this pid (isSameProcessInstance's own doc comment). Omitted entirely
2136
+ // when unreadable (non-Linux, permission) — the scavenger then falls back to the plain alive+TTL
2137
+ // check for this beacon, unchanged from before this residual hardening.
2138
+ const ownProcStart = pidStartedAtMsFromProcStat(process.pid);
2139
+ const procStartTag = ownProcStart !== undefined ? formatProcStart(ownProcStart) : '';
2140
+ // Lead delta after Codex round 2 (HIGH-4): this beacon states its OWN expiry, so a concurrent
2141
+ // probe with a different (shorter) budget can never out-vote this probe's protection window.
2142
+ const expiresTag = formatProbeExpires(started + ttlMs);
1754
2143
  const beaconPattern = {
1755
- pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken}`,
2144
+ pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken} ${ownerTag}${procStartTag}${expiresTag}`,
1756
2145
  type: 'lesson-learned',
1757
2146
  reward: 0,
1758
2147
  domain: PROBE_BEACON_DOMAIN,
@@ -1779,14 +2168,14 @@ export async function probeApplyLeg(root, opts = {}) {
1779
2168
  // Codex round-2: a process killed mid-probe bypasses `finally`, so a beacon can outlive its probe.
1780
2169
  // Every probe therefore starts by SCAVENGING any beacon left behind by an earlier one (the probe
1781
2170
  // 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 */ }
2171
+ // FR-3/FR-4: scavenging is no longer unconditional — a LIVE beacon from a DIFFERENT concurrent
2172
+ // probe (another session's `dz doctor`/`dz parity` against the same store) must survive; only a
2173
+ // beacon whose owner is dead or past its TTL is removed. A scavenge failure is a named FACT
2174
+ // (`scavengeError`), never a swallowed exception (the pre-fix `catch {}` this replaces).
2175
+ let scavengeError;
2176
+ const scavengeResult = scavengeStaleProbeBeacons(root, removeBeacon, ttlMs);
2177
+ if (!scavengeResult.ok)
2178
+ scavengeError = scavengeResult.error;
1790
2179
  try {
1791
2180
  wrote = true;
1792
2181
  let writeFailed;
@@ -1809,7 +2198,6 @@ export async function probeApplyLeg(root, opts = {}) {
1809
2198
  mkdirSync(join(tempCwd, '.dz'), { recursive: true });
1810
2199
  mkdirSync(join(tempCwd, '.claude'), { recursive: true });
1811
2200
  const { probeHookLiveness } = await import('./operations.js');
1812
- const timeoutMs = opts.timeoutMs ?? 8000;
1813
2201
  const probeResult = probeHookLiveness(command, JSON.stringify({ prompt: probePrompt }), {
1814
2202
  cwd: tempCwd,
1815
2203
  env: { ...(opts.env ?? {}), CLAUDE_PROJECT_DIR: tempCwd },
@@ -1829,13 +2217,13 @@ export async function probeApplyLeg(root, opts = {}) {
1829
2217
  }
1830
2218
  }
1831
2219
  if (typeof additionalContext === 'string' && additionalContext.includes(secretToken)) {
1832
- result = { ok: true, elapsedMs: elapsed() };
2220
+ result = { ok: true, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
1833
2221
  }
1834
2222
  else if (typeof additionalContext === 'string' && additionalContext.includes(queryToken)) {
1835
2223
  // AM-1: the QUERY came back but the SECRET did not — the hook (or a stub standing in for
1836
2224
  // it) echoed its own input instead of genuinely querying the store. Named distinctly from
1837
2225
  // 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() };
2226
+ result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
1839
2227
  }
1840
2228
  else {
1841
2229
  // FR-1's own reason line is the authoritative source — the hook names itself why it stayed
@@ -1844,10 +2232,10 @@ export async function probeApplyLeg(root, opts = {}) {
1844
2232
  const skipMatch = /\[dz-recall\] skipped reason=(\S+)/.exec(probeResult.stderr);
1845
2233
  const skipReason = skipMatch?.[1];
1846
2234
  if (skipReason !== undefined) {
1847
- result = { ok: false, reason: skipReason, elapsedMs: elapsed() };
2235
+ result = { ok: false, reason: skipReason, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
1848
2236
  }
1849
2237
  else if (probeResult.status === null) {
1850
- result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed() };
2238
+ result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
1851
2239
  }
1852
2240
  else {
1853
2241
  const stderrFirstLine = probeResult.stderr.trim().split('\n')[0];
@@ -1855,6 +2243,7 @@ export async function probeApplyLeg(root, opts = {}) {
1855
2243
  ok: false,
1856
2244
  reason: stderrFirstLine && stderrFirstLine !== '' ? stderrFirstLine : 'no beacon in additionalContext (empty or non-matching reply)',
1857
2245
  elapsedMs: elapsed(),
2246
+ groupKillAttempted: probeResult.groupKillAttempted,
1858
2247
  };
1859
2248
  }
1860
2249
  }
@@ -1889,12 +2278,21 @@ export async function probeApplyLeg(root, opts = {}) {
1889
2278
  }
1890
2279
  }
1891
2280
  if (cleanupFailed) {
2281
+ // a cleanup failure can only happen after `result` was assigned (the finally block runs after
2282
+ // the try body) — carry the kill-attempt fact through rather than dropping it on this path.
2283
+ // `exactOptionalPropertyTypes` forbids assigning `undefined` to an optional field explicitly, so
2284
+ // the key is included only when `result.groupKillAttempted` actually has a value.
1892
2285
  return {
1893
2286
  ok: false,
1894
2287
  reason: `beacon-cleanup-failed: beacon ${beaconId} could not be removed (${cleanupErrMsg})`,
1895
2288
  elapsedMs: elapsed(),
2289
+ ...(result.groupKillAttempted !== undefined ? { groupKillAttempted: result.groupKillAttempted } : {}),
2290
+ ...(scavengeError !== undefined ? { scavengeError } : {}),
1896
2291
  };
1897
2292
  }
1898
- return result;
2293
+ // FR-3: a scavenge failure is surfaced on the SUCCESS path too — it does not override `ok`/
2294
+ // `reason` (this probe's own injection result may be perfectly genuine), but it is a fact a
2295
+ // caller should not lose.
2296
+ return scavengeError !== undefined ? { ...result, scavengeError } : result;
1899
2297
  }
1900
2298
  //# sourceMappingURL=apply-leg.js.map