@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
@@ -34,6 +34,7 @@ import {
34
34
  readEmbedManifest,
35
35
  resolveEmbedModel,
36
36
  writeEmbedManifest,
37
+ type EmbedDtype,
37
38
  } from './embedding-config.js';
38
39
 
39
40
  /** One record to index. `text` is stored as `approach` AND embedded (`${taskType}: ${text}`). */
@@ -50,7 +51,15 @@ export interface AgentdbRow {
50
51
 
51
52
  /** Outcome of {@link indexPatternsToAgentdb}. `generationBumped`/`generationReason` are present only
52
53
  * when a store write actually happened (`indexed > 0`) — FR-4: a failed counter write NEVER fails
53
- * the indexing call itself, it is only reported so a caller (`dz doctor`, telemetry) can see it. */
54
+ * the indexing call itself, it is only reported so a caller (`dz doctor`, telemetry) can see it.
55
+ *
56
+ * Fix-round 1 (CRITICAL, item 1a): `indexed`/`generationBumped`/`generationReason` and `error` are
57
+ * NOT mutually exclusive. A failure AFTER the row commit (today, only `writeEmbedManifest` throwing)
58
+ * reports the REAL `indexed` count and the REAL bump outcome alongside `error` — it never collapses
59
+ * back to `{indexed: 0, error}` once rows are already on disk. Collapsing to `indexed: 0` after a
60
+ * real commit was the CRITICAL finding: a caller reading `indexed === 0` as "nothing happened" would
61
+ * skip its own rescue-bump logic even though the store had genuinely changed — an under-bump C-1
62
+ * forbids. */
54
63
  export interface AgentdbIndexResult {
55
64
  readonly indexed: number;
56
65
  readonly error?: string | undefined;
@@ -145,12 +154,38 @@ function generationFilePath(dbFile: string): string {
145
154
  return `${dbFile}.generation`;
146
155
  }
147
156
 
157
+ /** `<dbFile>.generation.recovered` (T2, `store-generation-residuals`, record `1d465496`) — the
158
+ * corrupt-sidecar recovery floor's own memory, a SEPARATE file next to the counter (never a
159
+ * module-level variable: two OS processes do not share one, and two processes are exactly what
160
+ * race here). Substring `.generation` deliberately preserved so any future sidecar-enumeration
161
+ * point that greps for the counter family (checked, none exists today — see Р-2 in
162
+ * `features/store-generation-residuals/06_implementation_plan.md`) still finds this file. */
163
+ function recoveredMemoryFilePath(genFile: string): string {
164
+ return `${genFile}.recovered`;
165
+ }
166
+
148
167
  /** AM-5 (fix-round): the ONLY shape {@link readStoreGeneration} trusts — one or more ASCII digits,
149
168
  * nothing else. `Number.parseInt` alone accepts a leading-numeric-with-trailing-junk string like
150
169
  * `"12junk"` as `12`; that reads a corrupt sidecar as a plausible generation instead of degrading to
151
170
  * the documented `0` compatibility floor. */
152
171
  const STRICT_GENERATION = /^\d+$/;
153
172
 
173
+ /** Shared degrade-to-0 read for any sidecar holding a single non-negative decimal integer: garbage,
174
+ * a missing file, or anything not matching {@link STRICT_GENERATION} reads as `0`, never throws.
175
+ * Both {@link readStoreGeneration} (the counter itself) and T2's recovery memory
176
+ * ({@link recoveredMemoryFilePath}) use this ONE primitive — the recovery memory must degrade
177
+ * exactly like the counter it accompanies (T2 NFR-1), not by a second, possibly-diverging rule. */
178
+ function readNonNegativeIntFile(path: string): number {
179
+ try {
180
+ const raw = readFileSync(path, 'utf8').trim();
181
+ if (!STRICT_GENERATION.test(raw)) return 0;
182
+ const n = Number.parseInt(raw, 10);
183
+ return Number.isFinite(n) && n >= 0 ? n : 0;
184
+ } catch {
185
+ return 0;
186
+ }
187
+ }
188
+
154
189
  /**
155
190
  * FR-2/FR-3 (`store-generation-counter`): the store's write-generation counter, read back. A
156
191
  * missing file (a store that predates this feature, or one that has never been written through
@@ -161,14 +196,7 @@ const STRICT_GENERATION = /^\d+$/;
161
196
  * to decide validity, only to convert an already-validated string.
162
197
  */
163
198
  export function readStoreGeneration(projectRoot: string, dbPath?: string): number {
164
- try {
165
- const raw = readFileSync(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)), 'utf8').trim();
166
- if (!STRICT_GENERATION.test(raw)) return 0;
167
- const n = Number.parseInt(raw, 10);
168
- return Number.isFinite(n) && n >= 0 ? n : 0;
169
- } catch {
170
- return 0;
171
- }
199
+ return readNonNegativeIntFile(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)));
172
200
  }
173
201
 
174
202
  /**
@@ -192,6 +220,37 @@ export function readStoreGeneration(projectRoot: string, dbPath?: string): numbe
192
220
  * regardless, telemetry is never a gate. AM-4: {@link resolveAgentdbPath} itself now runs INSIDE this
193
221
  * function's outer `try` — an unresolvable path can no longer throw OUT of `bumpStoreGeneration`
194
222
  * either; "never throws" now covers the whole function, not just the file-write tail.
223
+ *
224
+ * T2 (`store-generation-residuals`, record `1d465496`): the corrupt-sidecar recovery floor below
225
+ * used to be a bare `Date.now()` — NOT strictly monotonic on its own (two recoveries inside the same
226
+ * millisecond publish the same value; a backward clock step can publish a SMALLER one than an
227
+ * earlier recovery). It is now `max(now(), lastPublished + 1)`, where `lastPublished` is read from
228
+ * {@link recoveredMemoryFilePath} — a file, not a module-level variable, because the two writers who
229
+ * actually race here are two OS PROCESSES, which do not share process memory. `now` is an injectable
230
+ * time source (default `Date.now`) — AC-3's only reason to exist: a real clock cannot be rolled back
231
+ * from a test.
232
+ *
233
+ * Fix-round 1 (independent Codex review, gpt-5.6-sol — items 2/3/4/5/7), on top of T2:
234
+ * - item 2: {@link recoveredMemoryFilePath} now holds the LAST **published** generation, not the
235
+ * last **recovered** one — it is written on EVERY successful bump, not only inside the
236
+ * corrupt-sidecar branch. Before this fix, a run of ordinary bumps after a recovery left the
237
+ * memory stale, so a LATER recovery under a rolled-back clock could float the counter below a
238
+ * generation an ordinary bump already published (HIGH #2 finding).
239
+ * - item 3: the memory file is published BEFORE the counter file (was: counter first, memory
240
+ * "best-effort" after). A crash between the two writes now leaves the memory AHEAD of the counter
241
+ * — the SAFE direction: the next recovery floors too high rather than too low, so monotonicity
242
+ * survives a half-done bump (HIGH #3 finding).
243
+ * - item 4: a memory-write failure (e.g. a directory sitting at its path) does NOT block the counter
244
+ * publish below it — invalidation is the load-bearing behaviour — but the degradation is reported
245
+ * on stderr via {@link reportBumpMemoryDegraded}, never swallowed. LIMITATION: while the memory
246
+ * sidecar stays unwritable, a future corrupt-sidecar recovery on this store floors only at the wall
247
+ * clock, same as pre-fix-round behaviour — not strictly above every ordinary bump published in the
248
+ * meantime (HIGH #4 finding).
249
+ * - item 5: every error-to-string conversion in this function goes through {@link safeErrorMessage},
250
+ * which cannot itself throw even if `err` carries a poisoned `toString` — "never throws" is
251
+ * absolute (MEDIUM #5 finding).
252
+ * - item 7: the memory sidecar's own tmp file is cleaned up on a failed write, matching the counter's
253
+ * existing tmp-cleanup discipline (LOW #7 finding).
195
254
  */
196
255
  /** Codex round-2 (NEW HIGH): most mutators discard the bump result, so a failed bump must be
197
256
  * VISIBLE on its own — one stderr line, written by the helper itself. Telemetry never throws. */
@@ -200,13 +259,50 @@ function reportBumpFailure(error: string): { readonly ok: false; readonly error:
200
259
  return { ok: false, error };
201
260
  }
202
261
 
262
+ /** Fix-round 1, item 4: a bump whose COUNTER publish succeeded but whose recovery-memory sidecar
263
+ * ({@link recoveredMemoryFilePath}) could not be written must not swallow that fact — same
264
+ * stderr-report shape as {@link reportBumpFailure}, but this one never changes the return value:
265
+ * the counter genuinely advanced, so `{ok:true, generation}` stands. LIMITATION (documented here per
266
+ * the brief, item 4): until the memory sidecar is writable again, a FUTURE corrupt-sidecar recovery
267
+ * on this store is not guaranteed to floor above every generation an ordinary bump already published
268
+ * in the meantime (item 2's fix depends on the memory file being current) — it still floors above the
269
+ * wall clock, same as before this fix-round. */
270
+ function reportBumpMemoryDegraded(reason: string): void {
271
+ try {
272
+ process.stderr.write(
273
+ `dz: store generation recovery memory not updated — ${reason} — a future corrupt-sidecar recovery on this store is not guaranteed to stay strictly monotonic until this is fixed\n`,
274
+ );
275
+ } catch { /* telemetry never throws */ }
276
+ }
277
+
278
+ /** Fix-round 1, item 5: `String(err)` itself can throw if `err` carries a poisoned `toString` (or
279
+ * `Error.prototype.message` getter). `bumpStoreGeneration`'s "never throws" contract (FR-4) is
280
+ * ABSOLUTE, so every place in this function that turns a caught error into a string goes through
281
+ * this ONE protected helper — never a bare `err instanceof Error ? err.message : String(err)`.
282
+ * Exported test-only (same convention as {@link needsRescueBump}/{@link resetAgentdbEmbedderCache}).
283
+ */
284
+ export function safeErrorMessage(err: unknown): string {
285
+ try {
286
+ return err instanceof Error ? err.message : String(err);
287
+ } catch {
288
+ return '(unstringifiable error)';
289
+ }
290
+ }
291
+
203
292
  export function bumpStoreGeneration(
204
293
  projectRoot: string,
205
294
  dbPath?: string,
295
+ /** T2: the ONLY signature extension the plan permits — injectable wall clock, default `Date.now`,
296
+ * so AC-3 (a rolled-back system clock) can be reproduced without touching the real clock. */
297
+ now: () => number = Date.now,
206
298
  ): { readonly ok: true; readonly generation: number } | { readonly ok: false; readonly error: string } {
207
299
  try {
208
300
  const dbFile = resolveAgentdbPath(projectRoot, dbPath);
209
301
  const genFile = generationFilePath(dbFile);
302
+ // Fix-round 1, item 2: computed UNCONDITIONALLY (was: only inside the corrupt-sidecar branch) —
303
+ // this sidecar now tracks "last PUBLISHED generation", updated on every successful bump, not just
304
+ // a recovery.
305
+ const recoveredMemoryFile = recoveredMemoryFilePath(genFile);
210
306
  return withNamedLockSync(
211
307
  dirname(dbFile),
212
308
  'store-generation',
@@ -215,25 +311,61 @@ export function bumpStoreGeneration(
215
311
  // acquisition may already be stale, another holder may have advanced it in the meantime.
216
312
  let current = readStoreGeneration(projectRoot, dbPath);
217
313
  const tmp = `${genFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
314
+ const recTmp = `${recoveredMemoryFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
218
315
  try {
219
- // Codex round-2 (AM-2 residual): a sidecar that EXISTS but is corrupt reads as 0 and would
220
- // reset the counter to 1 — a value an engine-cache entry may already be keyed on. Floor a
221
- // corrupt value at a wall-clock stamp instead: still monotonic (ms since epoch exceeds any
222
- // count reached by bumping) and never colliding with an earlier generation. Absent file ⇒ 1.
316
+ // Codex round-2 (AM-2 residual) / T2 (record `1d465496`): a sidecar that EXISTS but is
317
+ // corrupt reads as 0 and would reset the counter to 1 — a value an engine-cache entry may
318
+ // already be keyed on. Floor a corrupt value at max(wall-clock, lastPublished+1) instead:
319
+ // still monotonic (ms-since-epoch exceeds any count reached by bumping) AND strictly
320
+ // increasing across successive corrupt recoveries even inside the same millisecond or
321
+ // across a backward clock step — the bare `Date.now()` this replaces was neither. Absent
322
+ // file (not corrupt, simply missing) still takes the ordinary `current === 0` path below,
323
+ // unaffected — only a genuinely corrupt EXISTING sidecar enters this branch.
223
324
  if (current === 0 && existsSync(genFile)) {
224
325
  const raw = readFileSync(genFile, 'utf8').trim();
225
- if (raw !== '0' && !STRICT_GENERATION.test(raw)) current = Date.now();
326
+ if (raw !== '0' && !STRICT_GENERATION.test(raw)) {
327
+ // NFR-1: a memory file that cannot be read (missing, or itself corrupt) degrades to 0,
328
+ // exactly like the counter's own read — never a throw, never a special-cased error.
329
+ // Fix-round 1, item 2: `lastPublished` now reflects every prior successful bump
330
+ // (ordinary or recovery), not only the previous recovery — see the doc comment above.
331
+ const lastPublished = readNonNegativeIntFile(recoveredMemoryFile);
332
+ current = Math.max(now(), lastPublished + 1);
333
+ }
226
334
  }
227
335
  const next = current + 1;
228
336
  mkdirSync(dirname(genFile), { recursive: true });
337
+
338
+ // Fix-round 1, item 3: the memory sidecar is published BEFORE the counter sidecar — a crash
339
+ // between the two then leaves the memory AHEAD of the counter (safe: the next recovery
340
+ // floors too high, never too low). Fix-round 1, item 2: this now runs on EVERY successful
341
+ // bump, not only inside the corrupt-sidecar branch above.
342
+ let memoryError: string | undefined;
343
+ try {
344
+ writeFileSync(recTmp, String(next), { encoding: 'utf8', flag: 'wx' });
345
+ renameSync(recTmp, recoveredMemoryFile);
346
+ } catch (memErr) {
347
+ // Fix-round 1, item 4: the counter publish below still goes ahead — invalidation matters
348
+ // more than the memory sidecar — but the degradation is reported, not swallowed (see the
349
+ // stderr write after the counter publish). Fix-round 1, item 7: clean up a half-written
350
+ // memory tmp file the same way the counter's own tmp is cleaned up on failure below.
351
+ try { if (existsSync(recTmp)) unlinkSync(recTmp); } catch { /* best-effort only */ }
352
+ memoryError = safeErrorMessage(memErr);
353
+ }
354
+
229
355
  writeFileSync(tmp, String(next), { encoding: 'utf8', flag: 'wx' });
230
356
  renameSync(tmp, genFile);
357
+
358
+ // Fix-round 1, item 4: reported AFTER the counter publish succeeds, so the stderr line
359
+ // never implies the bump itself failed — it names exactly the narrower, degraded guarantee.
360
+ if (memoryError !== undefined) reportBumpMemoryDegraded(memoryError);
361
+
231
362
  return { ok: true, generation: next };
232
363
  } catch (err) {
233
364
  // Best-effort cleanup of a half-written temp file (e.g. rename failed after a successful
234
365
  // write) so it never lingers as clutter — never lets a cleanup failure mask the real error.
235
366
  try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best-effort only */ }
236
- return reportBumpFailure(`store generation bump failed: ${err instanceof Error ? err.message : String(err)}`);
367
+ try { if (existsSync(recTmp)) unlinkSync(recTmp); } catch { /* best-effort only */ } // item 7
368
+ return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
237
369
  }
238
370
  },
239
371
  );
@@ -241,7 +373,7 @@ export function bumpStoreGeneration(
241
373
  // AM-2/FR-4: a lock that could not be acquired by its deadline (`NamedLockTimeoutError`) — and
242
374
  // any other failure reaching this point (an unresolvable path, AM-4) — degrades to the same
243
375
  // honest `{ok:false, error}` shape; it never throws into the store write it accompanies.
244
- return reportBumpFailure(`store generation bump failed: ${err instanceof Error ? err.message : String(err)}`);
376
+ return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
245
377
  }
246
378
  }
247
379
 
@@ -256,37 +388,25 @@ export async function indexPatternsToAgentdb(
256
388
  ): Promise<AgentdbIndexResult> {
257
389
  if (rows.length === 0) return { indexed: 0 };
258
390
  let sqliteUrl: string;
259
- let agentdbDir: string;
260
391
  try {
261
392
  const req = createRequire(join(projectRoot, 'package.json'));
262
393
  sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
263
- agentdbDir = dirname(req.resolve('agentdb'));
264
394
  } catch {
265
395
  return { indexed: 0, error: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
266
396
  }
267
397
  try {
268
398
  const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => NativeDb };
269
- const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
270
- EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
271
- };
272
399
  const model = resolveEmbedModel(projectRoot);
273
400
  if ('error' in model) return { indexed: 0, error: model.error };
274
- const emb = new EmbeddingService({
275
- model: model.model,
276
- dimension: model.dim,
277
- provider: 'transformers',
278
- // agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
279
- // (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
280
- // in dist/src/controllers/EmbeddingService.js:53). paraphrase-multilingual-MiniLM is a
281
- // SYMMETRIC sentence-transformer (no query/passage instruction prefixes), so the policy is
282
- // {kind:'symmetric'} — the same one the registry assigns its own symmetric models. On
283
- // alpha.18 the extra field is ignored; without it alpha.20 threw and the vector tier fell
284
- // to lexical SILENTLY (mirror writes answered {indexed:0, error} — measured 2026-08-24).
285
- rolePolicy: { kind: 'symmetric' },
286
- } as never);
287
- await emb.initialize();
288
-
289
401
  const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
402
+ // D1 (embed-daemon-memory, ADR-001): a single embedder per process — the SAME cached pipeline
403
+ // `resolveAgentdbEmbedder` hands to search/the daemon, never a private `new EmbeddingService(...)`
404
+ // built here. `dbFile` (not just `projectRoot`) so an EXISTING store's dtype (manifest, D2) wins
405
+ // over the config for an ordinary incremental index — only `reindexAgentdbRows` re-stamps the
406
+ // manifest first and thereby moves the dtype (see resolveStoreEmbedDtype's own doc comment).
407
+ const emb = await resolveAgentdbEmbedder(projectRoot, dbFile);
408
+ if ('error' in emb) return { indexed: 0, error: emb.error };
409
+
290
410
  const db = new Database(dbFile);
291
411
  try {
292
412
  db.pragma('journal_mode = WAL');
@@ -337,7 +457,21 @@ export async function indexPatternsToAgentdb(
337
457
  // whichever of the two calls below throws, the generation is already correct for the rows that
338
458
  // are already on disk.
339
459
  const bump = bumpStoreGeneration(projectRoot, opts.dbPath);
340
- writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
460
+ // Fix-round 1 (CRITICAL, item 1a): `writeEmbedManifest` can throw (a directory sitting at the
461
+ // manifest sidecar path — see the AM-3 test). Before this fix, that throw escaped to the outer
462
+ // `catch` below, which returned the GENERIC `{indexed: 0, error: ...}` — discarding the two
463
+ // facts already true by this point: `indexed` rows are on disk, and `bump` already ran. A
464
+ // caller reading `indexed === 0` would conclude "nothing happened" and skip its own rescue-bump
465
+ // logic even though the store had genuinely changed — the under-bump C-1 forbids. Catching the
466
+ // throw HERE, with the real `indexed`/bump outcome already captured in scope, preserves both.
467
+ try {
468
+ writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
469
+ } catch (manifestErr) {
470
+ const manifestError = `index failed: ${safeErrorMessage(manifestErr)}`;
471
+ return bump.ok
472
+ ? { indexed, generationBumped: true, error: manifestError }
473
+ : { indexed, generationBumped: false, generationReason: bump.error, error: manifestError };
474
+ }
341
475
  return bump.ok
342
476
  ? { indexed, generationBumped: true }
343
477
  : { indexed, generationBumped: false, generationReason: bump.error };
@@ -345,7 +479,7 @@ export async function indexPatternsToAgentdb(
345
479
  db.close();
346
480
  }
347
481
  } catch (err) {
348
- return { indexed: 0, error: `index failed: ${err instanceof Error ? err.message : String(err)}` };
482
+ return { indexed: 0, error: `index failed: ${safeErrorMessage(err)}` };
349
483
  }
350
484
  }
351
485
 
@@ -420,21 +554,147 @@ type Embedder = { embed: (t: string) => Promise<Float32Array> } | { error: strin
420
554
  */
421
555
  const embedderCache = new Map<string, Promise<Embedder>>();
422
556
  let embedderCacheInitializations = 0;
557
+ /**
558
+ * Fix round 1 (F6, Codex #6): `embedderCacheInitializations` only counts calls to
559
+ * {@link resolveAgentdbEmbedder} that missed the cache — it proves cache REUSE, not that a real
560
+ * pipeline was actually constructed. This counter increments at the exact two call sites where a
561
+ * pipeline construction primitive actually runs: the direct `pipeline('feature-extraction', …)` call
562
+ * in {@link initAgentdbEmbedder} and `EmbeddingService.initialize()` in
563
+ * {@link initViaAgentdbEmbeddingService} (the COMPAT FALLBACK path) — never merely on entry to
564
+ * `initAgentdbEmbedder`, which can also return an `{error}` (dtype:'q8' with no resolvable
565
+ * transformers, NFR-4) without ever attempting either.
566
+ */
567
+ let embedderCachePipelinesBuilt = 0;
423
568
 
424
569
  /** Test-only (and future warm-start) reset — callers (`vector-tier.ts`, `backlog.ts`) are unaffected. */
425
570
  export function resetAgentdbEmbedderCache(): void {
426
571
  embedderCache.clear();
427
572
  embedderCacheInitializations = 0;
573
+ embedderCachePipelinesBuilt = 0;
428
574
  }
429
575
 
430
576
  /** `entries` = cached keys right now — a SUCCESSFUL pipeline or an IN-FLIGHT initialization (the promise is
431
577
  * cached before it settles, FR-4; a failed one is evicted, FR-3); `initializations` = pipelines actually
432
- * started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.) */
433
- export function getAgentdbEmbedderCacheStats(): { entries: number; initializations: number } {
434
- return { entries: embedderCache.size, initializations: embedderCacheInitializations };
578
+ * started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.)
579
+ * `pipelinesBuilt` (fix round 1, F6) = the count of REAL pipeline-construction primitives that actually
580
+ * ran (`pipeline()` or `EmbeddingService.initialize()`), never merely the number of times the resolver
581
+ * was entered — see {@link embedderCachePipelinesBuilt}'s own doc comment for why the two can diverge. */
582
+ export function getAgentdbEmbedderCacheStats(): { entries: number; initializations: number; pipelinesBuilt: number } {
583
+ return { entries: embedderCache.size, initializations: embedderCacheInitializations, pipelinesBuilt: embedderCachePipelinesBuilt };
584
+ }
585
+
586
+ /**
587
+ * Walk UP from `startDir` (inclusive) looking for `<dir>/node_modules/<name>` as a real, existing
588
+ * path — a PLAIN FILESYSTEM CHECK, deliberately never `require.resolve()` alone. MEASURED
589
+ * 2026-09-16: under this package's own vitest harness, `createRequire(join(projectRoot,
590
+ * 'package.json')).resolve('@huggingface/transformers')` succeeds even for a deliberately isolated
591
+ * `/tmp` fixture that installs no such dependency at all (`agentdb-embedder-cache.test.ts`'s AC-3) —
592
+ * the test runner's module loader resolves more liberally than plain Node does, reaching the
593
+ * monorepo's real install regardless of `projectRoot`. `require.resolve` is used only AFTER this
594
+ * filesystem walk has already named a legitimate ancestor, so it can no longer be fooled that way.
595
+ * Returns the ancestor directory whose OWN `node_modules/<name>` exists, or `undefined` if none does
596
+ * all the way to the filesystem root (a handful of synchronous `existsSync` calls either way).
597
+ */
598
+ function findAncestorWithModule(startDir: string, name: string): string | undefined {
599
+ let dir = resolve(startDir);
600
+ for (;;) {
601
+ if (existsSync(join(dir, 'node_modules', name))) return dir;
602
+ const parent = dirname(dir);
603
+ if (parent === dir) return undefined;
604
+ dir = parent;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * C-3 (`embed-daemon-memory`): the SAME resolution order the daemon's own `resolveDeps` uses
610
+ * (`.claude/helpers/dz-embed-daemon.mjs`) — project `package.json` first, then `agentdb`'s own
611
+ * declared dependency (possibly hoisted elsewhere) — so core and the daemon agree on which install
612
+ * of transformers they find, in a monorepo or a plain install alike. Each candidate root is
613
+ * confirmed by {@link findAncestorWithModule} BEFORE `require.resolve` is trusted (see its own doc
614
+ * comment for why the plain try/catch this replaced was not safe under this package's test runner).
615
+ *
616
+ * Exported (fix round 1, F4, same convention as {@link safeErrorMessage}/{@link resetAgentdbEmbedderCache}):
617
+ * `embedder-single-owner.test.ts`'s live-dep skip gate needs the SAME resolution order the production
618
+ * code uses to decide, BEFORE running, whether a live embedder failure is a dependency gap (named skip)
619
+ * or a real defect (must fail) — a text-matching heuristic on the error message cannot tell those apart.
620
+ */
621
+ export function resolveTransformersModule(projectRoot: string): { url: string } | { error: string } {
622
+ const candidates = ['@huggingface/transformers', '@xenova/transformers'];
623
+ for (const name of candidates) {
624
+ const ancestor = findAncestorWithModule(projectRoot, name);
625
+ if (ancestor === undefined) continue;
626
+ try {
627
+ return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
628
+ } catch {
629
+ /* an ancestor that named the directory but whose require still can't resolve it (e.g. a
630
+ broken symlink) — try the next candidate */
631
+ }
632
+ }
633
+ let agentdbDir: string;
634
+ try {
635
+ agentdbDir = dirname(createRequire(join(projectRoot, 'package.json')).resolve('agentdb'));
636
+ } catch {
637
+ return { error: DEPS_MISSING };
638
+ }
639
+ for (const name of candidates) {
640
+ const ancestor = findAncestorWithModule(agentdbDir, name);
641
+ if (ancestor === undefined) continue;
642
+ try {
643
+ return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
644
+ } catch {
645
+ /* try the next candidate */
646
+ }
647
+ }
648
+ return { error: DEPS_MISSING };
649
+ }
650
+
651
+ /**
652
+ * D1 (ADR-001): builds the `transformers` pipeline DIRECTLY — the exact call agentdb's own
653
+ * `EmbeddingService.embed` makes for a symmetric model (`pipeline(text, { pooling:'mean',
654
+ * normalize:true })`, `EmbeddingService.js:205`), never through agentdb's wrapper class. This is
655
+ * the ONLY way to request `dtype:'q8'` at pipeline construction (D2) — `EmbeddingService.initialize`
656
+ * hardcodes `transformers.pipeline('feature-extraction', model)` with no dtype option at all, so a
657
+ * quantized store is unreachable through it at any dtype but fp32.
658
+ *
659
+ * COMPAT FALLBACK (deviation from the ADR's literal "иначе ядро отдаёт {error} как сегодня" —
660
+ * documented in `features/embed-daemon-memory/07_code_changes/change_manifest.md`): when
661
+ * `@huggingface/transformers`/`@xenova/transformers` cannot be resolved directly AND the requested
662
+ * dtype is the default `fp32`, this falls back to agentdb's `EmbeddingService` exactly as this
663
+ * function's pre-T2 body did. Measured (2026-09-16): seven OTHER features' test files
664
+ * (`agentdb-index.test.ts`, `agentdb-snapshot-{consistency,lock,rotation}.test.ts`, `brain.test.ts`,
665
+ * `quarantine-mirror-projection.test.ts`, `store-generation.test.ts`, `vector-tier{,​-rvf}.test.ts`)
666
+ * fake ONLY `agentdb/controllers/EmbeddingService.js` for their offline fixtures, never a
667
+ * `@huggingface/transformers`/`@xenova/transformers` stub — a literal "no fallback" implementation
668
+ * reddens all of them (out of scope here: `.claude/rules/cross-runtime-concurrency.md` and this
669
+ * feature's own hard rule both forbid touching another feature's files). `dtype:'q8'` NEVER falls
670
+ * back (NFR-4) — an unresolvable transformers module with `dtype:'q8'` requested is a hard `{error}`
671
+ * naming the model and dtype, exactly as the ADR specifies; only the fp32 path is widened.
672
+ */
673
+ async function initAgentdbEmbedder(projectRoot: string, agentdbDir: string, model: string, dim: number, dtype: EmbedDtype): Promise<Embedder> {
674
+ const transformers = resolveTransformersModule(projectRoot);
675
+ if (!('error' in transformers)) {
676
+ try {
677
+ const { pipeline } = (await import(transformers.url)) as {
678
+ pipeline: (task: string, model: string, opts?: Record<string, unknown>) => Promise<(t: string, o: Record<string, unknown>) => Promise<{ data: ArrayLike<number> }>>;
679
+ };
680
+ const extractor = await pipeline('feature-extraction', model, dtype === 'q8' ? { dtype: 'q8' } : {});
681
+ embedderCachePipelinesBuilt += 1; // F6: the real primitive ran and returned a usable extractor
682
+ return { embed: async (t: string) => Float32Array.from((await extractor(t, { pooling: 'mean', normalize: true })).data) };
683
+ } catch (err) {
684
+ return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${err instanceof Error ? err.message : String(err)}` };
685
+ }
686
+ }
687
+ if (dtype === 'q8') {
688
+ // NFR-4: a quantized store must never silently downgrade to fp32 for lack of a transformers
689
+ // install — the caller needs to know exactly why q8 is unreachable here.
690
+ return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${transformers.error}` };
691
+ }
692
+ return initViaAgentdbEmbeddingService(agentdbDir, model, dim);
435
693
  }
436
694
 
437
- async function initAgentdbEmbedder(agentdbDir: string, model: string, dim: number): Promise<Embedder> {
695
+ /** The pre-T2 implementation, preserved verbatim as the fp32-only COMPAT FALLBACK documented on
696
+ * {@link initAgentdbEmbedder} above. */
697
+ async function initViaAgentdbEmbeddingService(agentdbDir: string, model: string, dim: number): Promise<Embedder> {
438
698
  try {
439
699
  const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
440
700
  EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
@@ -453,6 +713,7 @@ async function initAgentdbEmbedder(agentdbDir: string, model: string, dim: numbe
453
713
  rolePolicy: { kind: 'symmetric' },
454
714
  } as never);
455
715
  await emb.initialize();
716
+ embedderCachePipelinesBuilt += 1; // F6: the COMPAT FALLBACK's own primitive ran
456
717
  return { embed: (t: string) => emb.embed(t) };
457
718
  } catch (err) {
458
719
  return { error: `embedder init failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -460,11 +721,45 @@ async function initAgentdbEmbedder(agentdbDir: string, model: string, dim: numbe
460
721
  }
461
722
 
462
723
  /**
463
- * Resolve agentdb's `EmbeddingService` from the PROJECT (same dynamic-resolution discipline as
464
- * {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model so query and row
465
- * vectors stay in the same space. Cached per process — see {@link embedderCache} above.
724
+ * D2 (`embed-daemon-memory`): the dtype a QUERY/write is embedded with is the STORE's own dtype
725
+ * (its manifest) when the store already exists, falling back to the CONFIGURED dtype only for a
726
+ * store that does not exist yet (its first-ever write picks up the config). This is the ONE place
727
+ * that decision is made — {@link resolveAgentdbEmbedder} calls it so every caller (search, an
728
+ * ordinary incremental index) agrees; `reindexAgentdbRows` is the sole exception (T2/plan): it
729
+ * stamps the manifest with the NEW configured dtype BEFORE it re-embeds, so by the time this
730
+ * function runs during a reindex the manifest already names the new dtype — config and manifest
731
+ * necessarily agree at that point, which is what makes reindex "the one place dtype changes".
732
+ */
733
+ export function resolveStoreEmbedDtype(projectRoot: string, dbPath?: string): EmbedDtype | { error: string } {
734
+ const configured = resolveEmbedModel(projectRoot);
735
+ if ('error' in configured) return { error: configured.error };
736
+ const manifest = readEmbedManifest(resolveAgentdbPath(projectRoot, dbPath));
737
+ // Fix round 1 (Codex #4): a manifest dtype that is PRESENT but unrecognized must refuse, not fall
738
+ // through to the configured default — the same discipline guardEmbedSpace applies, needed here too
739
+ // because THIS is what resolveAgentdbEmbedder actually keys its cache and pipeline construction on.
740
+ if (manifest?.readError !== undefined) {
741
+ return { error: `embedding manifest unreadable (${manifest.readError}); run dz vector reindex` };
742
+ }
743
+ if (manifest?.dtypeError !== undefined) {
744
+ return { error: `unknown embedding dtype "${manifest.dtypeError}" in manifest; run dz vector reindex` };
745
+ }
746
+ return manifest?.dtype ?? configured.dtype;
747
+ }
748
+
749
+ /**
750
+ * Resolve the shared embedder from the PROJECT (same dynamic-resolution discipline as
751
+ * {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model/dtype so query
752
+ * and row vectors stay in the same space. Cached per process — see {@link embedderCache} above.
753
+ *
754
+ * Fix round 1 (F1, doc correction — the prior wording was misleading): `dbPath` is passed straight
755
+ * to {@link resolveStoreEmbedDtype}, which calls {@link resolveAgentdbPath}`(projectRoot, dbPath)` —
756
+ * and THAT function already returns the project's DEFAULT store path (`<project>/.dz/agentdb.db`,
757
+ * or `AGENTDB_PATH`) when `dbPath` is omitted, not "no path". So an omitted `dbPath` still reads the
758
+ * default store's OWN manifest when one exists; the CONFIGURED dtype is used only as the fallback
759
+ * for a store that has no manifest yet (i.e. does not exist, or predates this feature) — never as
760
+ * the default behaviour for "no dbPath given".
466
761
  */
467
- export async function resolveAgentdbEmbedder(projectRoot: string): Promise<Embedder> {
762
+ export async function resolveAgentdbEmbedder(projectRoot: string, dbPath?: string): Promise<Embedder> {
468
763
  let agentdbDir: string;
469
764
  try {
470
765
  const req = createRequire(join(projectRoot, 'package.json'));
@@ -474,11 +769,13 @@ export async function resolveAgentdbEmbedder(projectRoot: string): Promise<Embed
474
769
  }
475
770
  const model = resolveEmbedModel(projectRoot);
476
771
  if ('error' in model) return { error: model.error };
477
- const key = `${agentdbDir}|${model.model}|${model.dim}`;
772
+ const dtype = resolveStoreEmbedDtype(projectRoot, dbPath);
773
+ if (typeof dtype === 'object' && 'error' in dtype) return { error: dtype.error };
774
+ const key = `${agentdbDir}|${model.model}|${model.dim}|${dtype}`;
478
775
  const hit = embedderCache.get(key);
479
776
  if (hit !== undefined) return hit;
480
777
  embedderCacheInitializations += 1;
481
- const promise = initAgentdbEmbedder(agentdbDir, model.model, model.dim);
778
+ const promise = initAgentdbEmbedder(projectRoot, agentdbDir, model.model, model.dim, dtype);
482
779
  embedderCache.set(key, promise);
483
780
  // FR-3: an init failure must not stick — evict so the next call retries instead of replaying
484
781
  // the same {error} forever. `.catch` here only guards a rejection that slips past
@@ -618,7 +915,10 @@ export async function searchAgentdbPatterns(
618
915
  reindexHint: opts.reindexHint ?? 'dz vector reindex',
619
916
  });
620
917
  if (!guard.ok) return { hits: [], error: guard.error };
621
- const emb = await resolveAgentdbEmbedder(projectRoot);
918
+ // D2: the query is embedded with the STORE's own dtype (the guard above already proved the
919
+ // manifest and the config agree) — pass the resolved store path so resolveAgentdbEmbedder reads
920
+ // the same manifest guardEmbedSpace just read, never the config's dtype in isolation.
921
+ const emb = await resolveAgentdbEmbedder(projectRoot, resolveAgentdbPath(projectRoot, opts.dbPath));
622
922
  if ('error' in emb) return { hits: [], error: emb.error };
623
923
  let qvec: Float32Array;
624
924
  try {
@@ -762,7 +1062,11 @@ interface UpsertDb {
762
1062
  pragma: (s: string) => void;
763
1063
  exec: (s: string) => void;
764
1064
  prepare: (q: string) => {
765
- run: (...a: unknown[]) => { lastInsertRowid: number | bigint };
1065
+ // T1 (`store-generation-residuals`, record `3cfcec83`): `changes` is added here (better-sqlite3
1066
+ // always returns it — this only makes an already-true fact visible to the type checker) so
1067
+ // `reindexAgentdbRows`'s own DELETE can read its OBSERVED row count instead of inferring it from
1068
+ // an adjacent signal (the exact mistake named in `06_implementation_plan.md`'s T1 section).
1069
+ run: (...a: unknown[]) => { changes: number; lastInsertRowid: number | bigint };
766
1070
  get: (...a: unknown[]) => unknown;
767
1071
  };
768
1072
  transaction: <T>(fn: () => T) => () => T;
@@ -1027,6 +1331,28 @@ export function bumpAgentdbUses(
1027
1331
  }
1028
1332
  }
1029
1333
 
1334
+ /**
1335
+ * Fix-round 1 (CRITICAL, item 1b — the belt): whether {@link reindexAgentdbRows} must run its own
1336
+ * rescue bump, given the DELETE's own observed `changes` count and the nested
1337
+ * {@link indexPatternsToAgentdb} call's result. Exported test-only (same convention as
1338
+ * {@link resetAgentdbEmbedderCache}) so the DECISION can be exercised directly and deterministically,
1339
+ * independent of forcing a real concurrent bump-lock race.
1340
+ *
1341
+ * `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined)`:
1342
+ * - `deleteChanges > 0` — the DELETE genuinely removed rows; the store changed regardless of the
1343
+ * nested call's outcome.
1344
+ * - `indexed.indexed > 0` — the nested call committed rows itself but its OWN bump failed
1345
+ * (`generationBumped: false`) or was never attempted.
1346
+ * - `indexed.error !== undefined` — the nested call's post-write state is UNKNOWN (item 1a: an error
1347
+ * here may still carry accurate `indexed`/`generationBumped` facts, but a caller must not assume a
1348
+ * future error path will). C-1: when in doubt, bump — an extra bump only over-invalidates a cache
1349
+ * (safe), a missed one serves stale data (not safe).
1350
+ */
1351
+ export function needsRescueBump(deleteChanges: number, indexed: AgentdbIndexResult): boolean {
1352
+ const nestedBumped = indexed.generationBumped === true;
1353
+ return !nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined);
1354
+ }
1355
+
1030
1356
  export async function reindexAgentdbRows(
1031
1357
  projectRoot: string,
1032
1358
  rows: readonly AgentdbRow[],
@@ -1238,6 +1564,11 @@ export async function reindexAgentdbRows(
1238
1564
  };
1239
1565
 
1240
1566
  let stale: string[] = [];
1567
+ // T1 (`store-generation-residuals`, record `3cfcec83`): the OBSERVED fact — `changes` from the
1568
+ // DELETE's own prepared-statement result, never inferred from `rows.length` or any other adjacent
1569
+ // signal (06_implementation_plan.md's T1 section names exactly that inference as the mistake to
1570
+ // avoid). Declared outside the `db` block so it survives to the bump decision below `db.close()`.
1571
+ let deleteChanges = 0;
1241
1572
  try {
1242
1573
  mkdirSync(dirname(dbFile), { recursive: true });
1243
1574
  const db = new Database(dbFile);
@@ -1255,11 +1586,11 @@ export async function reindexAgentdbRows(
1255
1586
  stale = foreignTaskTypesWithEmbeddings(db, taskTypes);
1256
1587
  const delEmb = db.prepare(`DELETE FROM pattern_embeddings WHERE pattern_id IN (SELECT id FROM reasoning_patterns WHERE task_type IN (${placeholders}))`);
1257
1588
  const delPat = db.prepare(`DELETE FROM reasoning_patterns WHERE task_type IN (${placeholders})`);
1258
- const tx = db.transaction(() => {
1589
+ const tx = db.transaction((): number => {
1259
1590
  delEmb.run(...taskTypes);
1260
- delPat.run(...taskTypes);
1591
+ return delPat.run(...taskTypes).changes;
1261
1592
  });
1262
- tx();
1593
+ deleteChanges = tx();
1263
1594
  } finally {
1264
1595
  db.close();
1265
1596
  }
@@ -1274,6 +1605,15 @@ export async function reindexAgentdbRows(
1274
1605
 
1275
1606
  const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
1276
1607
  if (indexed.error !== undefined) {
1608
+ // Fix-round 1 (CRITICAL, item 1b — the belt): before this fix, NO bump was attempted anywhere
1609
+ // on this branch, regardless of `deleteChanges` — the DELETE above may have genuinely removed
1610
+ // rows from the store (a real change on disk) and the counter would never move to reflect it,
1611
+ // even though `rollback()` below may itself fail and leave that changed state in place. An
1612
+ // error from the nested call means the post-write state is UNKNOWN — C-1 resolves unknown in
1613
+ // favour of bumping (a spurious extra bump only over-invalidates a cache; a missed one serves
1614
+ // stale data). `needsRescueBump` is the SAME decision used on the success path below — one
1615
+ // rule, not two that could drift apart.
1616
+ if (needsRescueBump(deleteChanges, indexed)) bumpStoreGeneration(projectRoot, opts.dbPath);
1277
1617
  const rb = await rollback();
1278
1618
  if (rb.restored === 'failed') rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
1279
1619
  return {
@@ -1301,13 +1641,36 @@ export async function reindexAgentdbRows(
1301
1641
  if (!(err instanceof NamedLockTimeoutError)) throw err;
1302
1642
  snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
1303
1643
  }
1304
- // AM-1 (fix-round): `reindexAgentdbRows` is itself a mutator (the DELETE above rebuilds the
1305
- // owned task types) — it must not rely SOLELY on `indexPatternsToAgentdb`'s own internal bump,
1306
- // because that call is a no-op (and bumps nothing) when `rows` is empty, yet the DELETE it ran
1307
- // just above unconditionally changed the store. Bumping again here when `rows` was non-empty
1308
- // (the common case, already bumped once inside `indexPatternsToAgentdb`) is harmless — the
1309
- // counter is a monotonic "did anything change" signal, not a per-operation tally.
1310
- bumpStoreGeneration(projectRoot, opts.dbPath);
1644
+ // T1 (`store-generation-residuals`, record `3cfcec83` — supersedes the AM-1 comment this
1645
+ // replaces, which documented the double-bump as "harmless" rather than fixing it). AM-1's
1646
+ // underlying concern stands unchanged: `reindexAgentdbRows` is ITSELF a mutator (the DELETE
1647
+ // above rebuilds the owned task types) and must not rely solely on `indexPatternsToAgentdb`'s
1648
+ // own internal bump, because that nested call is a no-op — bumps nothing, sets no
1649
+ // `generationBumped` — when `rows` is empty, yet the DELETE just above may have changed the
1650
+ // store regardless of whether there was anything to re-insert.
1651
+ //
1652
+ // The rule (now the shared {@link needsRescueBump} helper — fix-round 1, item 1b — used
1653
+ // identically on the error branch above) uses OBSERVED facts, never inferred from an adjacent
1654
+ // signal (the mistake named in the plan's T1 section, fresh from the worker-ceiling fix that
1655
+ // predates this one): `deleteChanges` is the DELETE's own `changes` count, read directly off the
1656
+ // prepared-statement result; `indexed.generationBumped` is a field `indexPatternsToAgentdb` sets
1657
+ // ONLY where its own bump actually ran (never guessed from `indexed.indexed > 0`, which is
1658
+ // itself a real fact but a DIFFERENT one — see below).
1659
+ //
1660
+ // The condition is intentionally `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 ||
1661
+ // indexed.error !== undefined)`, NOT the narrower `deleteChanges > 0 && !nestedBumped` the
1662
+ // plan's prose formula reads as: a bare `deleteChanges > 0` gate would MISS the case where the
1663
+ // DELETE removed nothing (a first-ever reindex of these task types) but the nested insert then
1664
+ // ran and its OWN bump failed (`indexed.generationBumped === false`, e.g. a transient lock
1665
+ // timeout) — under the narrower gate the store would have changed on disk with no rescue bump at
1666
+ // all, a genuine under-bump. C-1 (`01_requirements.md`) makes correctness here non-negotiable:
1667
+ // "при сомнении поднимать счётчик ЛИШНИЙ раз безопаснее, чем не поднять" — so the OR-of-facts
1668
+ // form below is what actually ships; it satisfies every case FR-1's AC-1 enumerates AND closes
1669
+ // the gaps the plan's literal formula and the pre-fix-round-1 condition left open, verified by
1670
+ // exhaustive case analysis in `features/store-generation-residuals/07_code_changes/change_manifest.md`.
1671
+ if (needsRescueBump(deleteChanges, indexed)) {
1672
+ bumpStoreGeneration(projectRoot, opts.dbPath);
1673
+ }
1311
1674
  return {
1312
1675
  reembedded: indexed.indexed,
1313
1676
  model: model.model,