@dzhechkov/harness-core 0.8.36 → 0.8.38

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 (112) hide show
  1. package/.dz-manifest.json +216 -76
  2. package/README.md +349 -8
  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 +19 -1
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +187 -36
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-rollouts.d.ts +118 -0
  12. package/dist/codex-rollouts.d.ts.map +1 -0
  13. package/dist/codex-rollouts.js +297 -0
  14. package/dist/codex-rollouts.js.map +1 -0
  15. package/dist/cost-ledger.d.ts +56 -4
  16. package/dist/cost-ledger.d.ts.map +1 -1
  17. package/dist/cost-ledger.js +176 -20
  18. package/dist/cost-ledger.js.map +1 -1
  19. package/dist/cross-family-control.d.ts +380 -0
  20. package/dist/cross-family-control.d.ts.map +1 -0
  21. package/dist/cross-family-control.js +848 -0
  22. package/dist/cross-family-control.js.map +1 -0
  23. package/dist/debt-ratchet.d.ts +53 -0
  24. package/dist/debt-ratchet.d.ts.map +1 -0
  25. package/dist/debt-ratchet.js +107 -0
  26. package/dist/debt-ratchet.js.map +1 -0
  27. package/dist/embedding-config.d.ts +42 -0
  28. package/dist/embedding-config.d.ts.map +1 -1
  29. package/dist/embedding-config.js +106 -10
  30. package/dist/embedding-config.js.map +1 -1
  31. package/dist/feature-adr-checkpoints.d.ts +6 -0
  32. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  33. package/dist/feature-adr-checkpoints.js +29 -0
  34. package/dist/feature-adr-checkpoints.js.map +1 -1
  35. package/dist/feature-adr-decision-recall.d.ts +2 -2
  36. package/dist/feature-adr-decision-recall.d.ts.map +1 -1
  37. package/dist/feature-adr-decision-recall.js +5 -3
  38. package/dist/feature-adr-decision-recall.js.map +1 -1
  39. package/dist/feature-adr-envelope.d.ts +96 -0
  40. package/dist/feature-adr-envelope.d.ts.map +1 -0
  41. package/dist/feature-adr-envelope.js +183 -0
  42. package/dist/feature-adr-envelope.js.map +1 -0
  43. package/dist/feature-adr-routing.d.ts +64 -0
  44. package/dist/feature-adr-routing.d.ts.map +1 -1
  45. package/dist/feature-adr-routing.js +133 -3
  46. package/dist/feature-adr-routing.js.map +1 -1
  47. package/dist/feature-adr-stage-canon.d.ts +79 -0
  48. package/dist/feature-adr-stage-canon.d.ts.map +1 -0
  49. package/dist/feature-adr-stage-canon.js +117 -0
  50. package/dist/feature-adr-stage-canon.js.map +1 -0
  51. package/dist/index.d.ts +21 -9
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +15 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/loop-blobs.generated.js +4 -4
  56. package/dist/loop-blobs.generated.js.map +1 -1
  57. package/dist/mutation-gate.d.ts +51 -0
  58. package/dist/mutation-gate.d.ts.map +1 -1
  59. package/dist/mutation-gate.js +295 -0
  60. package/dist/mutation-gate.js.map +1 -1
  61. package/dist/qe-bridge.d.ts +8 -0
  62. package/dist/qe-bridge.d.ts.map +1 -1
  63. package/dist/qe-bridge.js +4 -2
  64. package/dist/qe-bridge.js.map +1 -1
  65. package/dist/qe-findings.d.ts +107 -0
  66. package/dist/qe-findings.d.ts.map +1 -0
  67. package/dist/qe-findings.js +417 -0
  68. package/dist/qe-findings.js.map +1 -0
  69. package/dist/recap.d.ts +1 -1
  70. package/dist/recap.d.ts.map +1 -1
  71. package/dist/recap.js +4 -2
  72. package/dist/recap.js.map +1 -1
  73. package/dist/review-cost.d.ts +51 -0
  74. package/dist/review-cost.d.ts.map +1 -0
  75. package/dist/review-cost.js +110 -0
  76. package/dist/review-cost.js.map +1 -0
  77. package/dist/round.d.ts +207 -1
  78. package/dist/round.d.ts.map +1 -1
  79. package/dist/round.js +321 -4
  80. package/dist/round.js.map +1 -1
  81. package/dist/run-records.d.ts +97 -0
  82. package/dist/run-records.d.ts.map +1 -1
  83. package/dist/run-records.js +336 -2
  84. package/dist/run-records.js.map +1 -1
  85. package/dist/score.d.ts +44 -1
  86. package/dist/score.d.ts.map +1 -1
  87. package/dist/score.js +78 -5
  88. package/dist/score.js.map +1 -1
  89. package/package.json +1 -1
  90. package/sbom.json +425 -75
  91. package/src/agentdb-index.ts +423 -60
  92. package/src/apply-leg.ts +187 -36
  93. package/src/codex-rollouts.ts +374 -0
  94. package/src/cost-ledger.ts +232 -24
  95. package/src/cross-family-control.ts +1038 -0
  96. package/src/debt-ratchet.ts +143 -0
  97. package/src/embedding-config.ts +131 -10
  98. package/src/feature-adr-checkpoints.ts +29 -0
  99. package/src/feature-adr-decision-recall.ts +6 -4
  100. package/src/feature-adr-envelope.ts +242 -0
  101. package/src/feature-adr-routing.ts +150 -3
  102. package/src/feature-adr-stage-canon.ts +141 -0
  103. package/src/index.ts +65 -6
  104. package/src/loop-blobs.generated.ts +4 -4
  105. package/src/mutation-gate.ts +316 -0
  106. package/src/qe-bridge.ts +12 -2
  107. package/src/qe-findings.ts +463 -0
  108. package/src/recap.ts +10 -3
  109. package/src/review-cost.ts +139 -0
  110. package/src/round.ts +481 -6
  111. package/src/run-records.ts +388 -2
  112. package/src/score.ts +115 -6
@@ -100,11 +100,37 @@ export function ensureAgentdbSchema(projectRoot, dbPath) {
100
100
  function generationFilePath(dbFile) {
101
101
  return `${dbFile}.generation`;
102
102
  }
103
+ /** `<dbFile>.generation.recovered` (T2, `store-generation-residuals`, record `1d465496`) — the
104
+ * corrupt-sidecar recovery floor's own memory, a SEPARATE file next to the counter (never a
105
+ * module-level variable: two OS processes do not share one, and two processes are exactly what
106
+ * race here). Substring `.generation` deliberately preserved so any future sidecar-enumeration
107
+ * point that greps for the counter family (checked, none exists today — see Р-2 in
108
+ * `features/store-generation-residuals/06_implementation_plan.md`) still finds this file. */
109
+ function recoveredMemoryFilePath(genFile) {
110
+ return `${genFile}.recovered`;
111
+ }
103
112
  /** AM-5 (fix-round): the ONLY shape {@link readStoreGeneration} trusts — one or more ASCII digits,
104
113
  * nothing else. `Number.parseInt` alone accepts a leading-numeric-with-trailing-junk string like
105
114
  * `"12junk"` as `12`; that reads a corrupt sidecar as a plausible generation instead of degrading to
106
115
  * the documented `0` compatibility floor. */
107
116
  const STRICT_GENERATION = /^\d+$/;
117
+ /** Shared degrade-to-0 read for any sidecar holding a single non-negative decimal integer: garbage,
118
+ * a missing file, or anything not matching {@link STRICT_GENERATION} reads as `0`, never throws.
119
+ * Both {@link readStoreGeneration} (the counter itself) and T2's recovery memory
120
+ * ({@link recoveredMemoryFilePath}) use this ONE primitive — the recovery memory must degrade
121
+ * exactly like the counter it accompanies (T2 NFR-1), not by a second, possibly-diverging rule. */
122
+ function readNonNegativeIntFile(path) {
123
+ try {
124
+ const raw = readFileSync(path, 'utf8').trim();
125
+ if (!STRICT_GENERATION.test(raw))
126
+ return 0;
127
+ const n = Number.parseInt(raw, 10);
128
+ return Number.isFinite(n) && n >= 0 ? n : 0;
129
+ }
130
+ catch {
131
+ return 0;
132
+ }
133
+ }
108
134
  /**
109
135
  * FR-2/FR-3 (`store-generation-counter`): the store's write-generation counter, read back. A
110
136
  * missing file (a store that predates this feature, or one that has never been written through
@@ -115,16 +141,7 @@ const STRICT_GENERATION = /^\d+$/;
115
141
  * to decide validity, only to convert an already-validated string.
116
142
  */
117
143
  export function readStoreGeneration(projectRoot, dbPath) {
118
- try {
119
- const raw = readFileSync(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)), 'utf8').trim();
120
- if (!STRICT_GENERATION.test(raw))
121
- return 0;
122
- const n = Number.parseInt(raw, 10);
123
- return Number.isFinite(n) && n >= 0 ? n : 0;
124
- }
125
- catch {
126
- return 0;
127
- }
144
+ return readNonNegativeIntFile(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)));
128
145
  }
129
146
  /**
130
147
  * FR-1 (`store-generation-counter`): monotonically advance the store's write-generation counter by
@@ -147,6 +164,37 @@ export function readStoreGeneration(projectRoot, dbPath) {
147
164
  * regardless, telemetry is never a gate. AM-4: {@link resolveAgentdbPath} itself now runs INSIDE this
148
165
  * function's outer `try` — an unresolvable path can no longer throw OUT of `bumpStoreGeneration`
149
166
  * either; "never throws" now covers the whole function, not just the file-write tail.
167
+ *
168
+ * T2 (`store-generation-residuals`, record `1d465496`): the corrupt-sidecar recovery floor below
169
+ * used to be a bare `Date.now()` — NOT strictly monotonic on its own (two recoveries inside the same
170
+ * millisecond publish the same value; a backward clock step can publish a SMALLER one than an
171
+ * earlier recovery). It is now `max(now(), lastPublished + 1)`, where `lastPublished` is read from
172
+ * {@link recoveredMemoryFilePath} — a file, not a module-level variable, because the two writers who
173
+ * actually race here are two OS PROCESSES, which do not share process memory. `now` is an injectable
174
+ * time source (default `Date.now`) — AC-3's only reason to exist: a real clock cannot be rolled back
175
+ * from a test.
176
+ *
177
+ * Fix-round 1 (independent Codex review, gpt-5.6-sol — items 2/3/4/5/7), on top of T2:
178
+ * - item 2: {@link recoveredMemoryFilePath} now holds the LAST **published** generation, not the
179
+ * last **recovered** one — it is written on EVERY successful bump, not only inside the
180
+ * corrupt-sidecar branch. Before this fix, a run of ordinary bumps after a recovery left the
181
+ * memory stale, so a LATER recovery under a rolled-back clock could float the counter below a
182
+ * generation an ordinary bump already published (HIGH #2 finding).
183
+ * - item 3: the memory file is published BEFORE the counter file (was: counter first, memory
184
+ * "best-effort" after). A crash between the two writes now leaves the memory AHEAD of the counter
185
+ * — the SAFE direction: the next recovery floors too high rather than too low, so monotonicity
186
+ * survives a half-done bump (HIGH #3 finding).
187
+ * - item 4: a memory-write failure (e.g. a directory sitting at its path) does NOT block the counter
188
+ * publish below it — invalidation is the load-bearing behaviour — but the degradation is reported
189
+ * on stderr via {@link reportBumpMemoryDegraded}, never swallowed. LIMITATION: while the memory
190
+ * sidecar stays unwritable, a future corrupt-sidecar recovery on this store floors only at the wall
191
+ * clock, same as pre-fix-round behaviour — not strictly above every ordinary bump published in the
192
+ * meantime (HIGH #4 finding).
193
+ * - item 5: every error-to-string conversion in this function goes through {@link safeErrorMessage},
194
+ * which cannot itself throw even if `err` carries a poisoned `toString` — "never throws" is
195
+ * absolute (MEDIUM #5 finding).
196
+ * - item 7: the memory sidecar's own tmp file is cleaned up on a failed write, matching the counter's
197
+ * existing tmp-cleanup discipline (LOW #7 finding).
150
198
  */
151
199
  /** Codex round-2 (NEW HIGH): most mutators discard the bump result, so a failed bump must be
152
200
  * VISIBLE on its own — one stderr line, written by the helper itself. Telemetry never throws. */
@@ -157,29 +205,100 @@ function reportBumpFailure(error) {
157
205
  catch { /* telemetry never throws */ }
158
206
  return { ok: false, error };
159
207
  }
160
- export function bumpStoreGeneration(projectRoot, dbPath) {
208
+ /** Fix-round 1, item 4: a bump whose COUNTER publish succeeded but whose recovery-memory sidecar
209
+ * ({@link recoveredMemoryFilePath}) could not be written must not swallow that fact — same
210
+ * stderr-report shape as {@link reportBumpFailure}, but this one never changes the return value:
211
+ * the counter genuinely advanced, so `{ok:true, generation}` stands. LIMITATION (documented here per
212
+ * the brief, item 4): until the memory sidecar is writable again, a FUTURE corrupt-sidecar recovery
213
+ * on this store is not guaranteed to floor above every generation an ordinary bump already published
214
+ * in the meantime (item 2's fix depends on the memory file being current) — it still floors above the
215
+ * wall clock, same as before this fix-round. */
216
+ function reportBumpMemoryDegraded(reason) {
217
+ try {
218
+ process.stderr.write(`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`);
219
+ }
220
+ catch { /* telemetry never throws */ }
221
+ }
222
+ /** Fix-round 1, item 5: `String(err)` itself can throw if `err` carries a poisoned `toString` (or
223
+ * `Error.prototype.message` getter). `bumpStoreGeneration`'s "never throws" contract (FR-4) is
224
+ * ABSOLUTE, so every place in this function that turns a caught error into a string goes through
225
+ * this ONE protected helper — never a bare `err instanceof Error ? err.message : String(err)`.
226
+ * Exported test-only (same convention as {@link needsRescueBump}/{@link resetAgentdbEmbedderCache}).
227
+ */
228
+ export function safeErrorMessage(err) {
229
+ try {
230
+ return err instanceof Error ? err.message : String(err);
231
+ }
232
+ catch {
233
+ return '(unstringifiable error)';
234
+ }
235
+ }
236
+ export function bumpStoreGeneration(projectRoot, dbPath,
237
+ /** T2: the ONLY signature extension the plan permits — injectable wall clock, default `Date.now`,
238
+ * so AC-3 (a rolled-back system clock) can be reproduced without touching the real clock. */
239
+ now = Date.now) {
161
240
  try {
162
241
  const dbFile = resolveAgentdbPath(projectRoot, dbPath);
163
242
  const genFile = generationFilePath(dbFile);
243
+ // Fix-round 1, item 2: computed UNCONDITIONALLY (was: only inside the corrupt-sidecar branch) —
244
+ // this sidecar now tracks "last PUBLISHED generation", updated on every successful bump, not just
245
+ // a recovery.
246
+ const recoveredMemoryFile = recoveredMemoryFilePath(genFile);
164
247
  return withNamedLockSync(dirname(dbFile), 'store-generation', () => {
165
248
  // AM-2: re-read the CURRENT value from disk while holding the lock — a value observed before
166
249
  // acquisition may already be stale, another holder may have advanced it in the meantime.
167
250
  let current = readStoreGeneration(projectRoot, dbPath);
168
251
  const tmp = `${genFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
252
+ const recTmp = `${recoveredMemoryFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
169
253
  try {
170
- // Codex round-2 (AM-2 residual): a sidecar that EXISTS but is corrupt reads as 0 and would
171
- // reset the counter to 1 — a value an engine-cache entry may already be keyed on. Floor a
172
- // corrupt value at a wall-clock stamp instead: still monotonic (ms since epoch exceeds any
173
- // count reached by bumping) and never colliding with an earlier generation. Absent file ⇒ 1.
254
+ // Codex round-2 (AM-2 residual) / T2 (record `1d465496`): a sidecar that EXISTS but is
255
+ // corrupt reads as 0 and would reset the counter to 1 — a value an engine-cache entry may
256
+ // already be keyed on. Floor a corrupt value at max(wall-clock, lastPublished+1) instead:
257
+ // still monotonic (ms-since-epoch exceeds any count reached by bumping) AND strictly
258
+ // increasing across successive corrupt recoveries even inside the same millisecond or
259
+ // across a backward clock step — the bare `Date.now()` this replaces was neither. Absent
260
+ // file (not corrupt, simply missing) still takes the ordinary `current === 0` path below,
261
+ // unaffected — only a genuinely corrupt EXISTING sidecar enters this branch.
174
262
  if (current === 0 && existsSync(genFile)) {
175
263
  const raw = readFileSync(genFile, 'utf8').trim();
176
- if (raw !== '0' && !STRICT_GENERATION.test(raw))
177
- current = Date.now();
264
+ if (raw !== '0' && !STRICT_GENERATION.test(raw)) {
265
+ // NFR-1: a memory file that cannot be read (missing, or itself corrupt) degrades to 0,
266
+ // exactly like the counter's own read — never a throw, never a special-cased error.
267
+ // Fix-round 1, item 2: `lastPublished` now reflects every prior successful bump
268
+ // (ordinary or recovery), not only the previous recovery — see the doc comment above.
269
+ const lastPublished = readNonNegativeIntFile(recoveredMemoryFile);
270
+ current = Math.max(now(), lastPublished + 1);
271
+ }
178
272
  }
179
273
  const next = current + 1;
180
274
  mkdirSync(dirname(genFile), { recursive: true });
275
+ // Fix-round 1, item 3: the memory sidecar is published BEFORE the counter sidecar — a crash
276
+ // between the two then leaves the memory AHEAD of the counter (safe: the next recovery
277
+ // floors too high, never too low). Fix-round 1, item 2: this now runs on EVERY successful
278
+ // bump, not only inside the corrupt-sidecar branch above.
279
+ let memoryError;
280
+ try {
281
+ writeFileSync(recTmp, String(next), { encoding: 'utf8', flag: 'wx' });
282
+ renameSync(recTmp, recoveredMemoryFile);
283
+ }
284
+ catch (memErr) {
285
+ // Fix-round 1, item 4: the counter publish below still goes ahead — invalidation matters
286
+ // more than the memory sidecar — but the degradation is reported, not swallowed (see the
287
+ // stderr write after the counter publish). Fix-round 1, item 7: clean up a half-written
288
+ // memory tmp file the same way the counter's own tmp is cleaned up on failure below.
289
+ try {
290
+ if (existsSync(recTmp))
291
+ unlinkSync(recTmp);
292
+ }
293
+ catch { /* best-effort only */ }
294
+ memoryError = safeErrorMessage(memErr);
295
+ }
181
296
  writeFileSync(tmp, String(next), { encoding: 'utf8', flag: 'wx' });
182
297
  renameSync(tmp, genFile);
298
+ // Fix-round 1, item 4: reported AFTER the counter publish succeeds, so the stderr line
299
+ // never implies the bump itself failed — it names exactly the narrower, degraded guarantee.
300
+ if (memoryError !== undefined)
301
+ reportBumpMemoryDegraded(memoryError);
183
302
  return { ok: true, generation: next };
184
303
  }
185
304
  catch (err) {
@@ -190,7 +309,12 @@ export function bumpStoreGeneration(projectRoot, dbPath) {
190
309
  unlinkSync(tmp);
191
310
  }
192
311
  catch { /* best-effort only */ }
193
- return reportBumpFailure(`store generation bump failed: ${err instanceof Error ? err.message : String(err)}`);
312
+ try {
313
+ if (existsSync(recTmp))
314
+ unlinkSync(recTmp);
315
+ }
316
+ catch { /* best-effort only */ } // item 7
317
+ return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
194
318
  }
195
319
  });
196
320
  }
@@ -198,7 +322,7 @@ export function bumpStoreGeneration(projectRoot, dbPath) {
198
322
  // AM-2/FR-4: a lock that could not be acquired by its deadline (`NamedLockTimeoutError`) — and
199
323
  // any other failure reaching this point (an unresolvable path, AM-4) — degrades to the same
200
324
  // honest `{ok:false, error}` shape; it never throws into the store write it accompanies.
201
- return reportBumpFailure(`store generation bump failed: ${err instanceof Error ? err.message : String(err)}`);
325
+ return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
202
326
  }
203
327
  }
204
328
  /**
@@ -209,36 +333,27 @@ export async function indexPatternsToAgentdb(projectRoot, rows, opts = {}) {
209
333
  if (rows.length === 0)
210
334
  return { indexed: 0 };
211
335
  let sqliteUrl;
212
- let agentdbDir;
213
336
  try {
214
337
  const req = createRequire(join(projectRoot, 'package.json'));
215
338
  sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
216
- agentdbDir = dirname(req.resolve('agentdb'));
217
339
  }
218
340
  catch {
219
341
  return { indexed: 0, error: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
220
342
  }
221
343
  try {
222
344
  const { default: Database } = (await import(sqliteUrl));
223
- const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href));
224
345
  const model = resolveEmbedModel(projectRoot);
225
346
  if ('error' in model)
226
347
  return { indexed: 0, error: model.error };
227
- const emb = new EmbeddingService({
228
- model: model.model,
229
- dimension: model.dim,
230
- provider: 'transformers',
231
- // agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
232
- // (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
233
- // in dist/src/controllers/EmbeddingService.js:53). paraphrase-multilingual-MiniLM is a
234
- // SYMMETRIC sentence-transformer (no query/passage instruction prefixes), so the policy is
235
- // {kind:'symmetric'} — the same one the registry assigns its own symmetric models. On
236
- // alpha.18 the extra field is ignored; without it alpha.20 threw and the vector tier fell
237
- // to lexical SILENTLY (mirror writes answered {indexed:0, error} — measured 2026-08-24).
238
- rolePolicy: { kind: 'symmetric' },
239
- });
240
- await emb.initialize();
241
348
  const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
349
+ // D1 (embed-daemon-memory, ADR-001): a single embedder per process — the SAME cached pipeline
350
+ // `resolveAgentdbEmbedder` hands to search/the daemon, never a private `new EmbeddingService(...)`
351
+ // built here. `dbFile` (not just `projectRoot`) so an EXISTING store's dtype (manifest, D2) wins
352
+ // over the config for an ordinary incremental index — only `reindexAgentdbRows` re-stamps the
353
+ // manifest first and thereby moves the dtype (see resolveStoreEmbedDtype's own doc comment).
354
+ const emb = await resolveAgentdbEmbedder(projectRoot, dbFile);
355
+ if ('error' in emb)
356
+ return { indexed: 0, error: emb.error };
242
357
  const db = new Database(dbFile);
243
358
  try {
244
359
  db.pragma('journal_mode = WAL');
@@ -282,7 +397,22 @@ export async function indexPatternsToAgentdb(projectRoot, rows, opts = {}) {
282
397
  // whichever of the two calls below throws, the generation is already correct for the rows that
283
398
  // are already on disk.
284
399
  const bump = bumpStoreGeneration(projectRoot, opts.dbPath);
285
- writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
400
+ // Fix-round 1 (CRITICAL, item 1a): `writeEmbedManifest` can throw (a directory sitting at the
401
+ // manifest sidecar path — see the AM-3 test). Before this fix, that throw escaped to the outer
402
+ // `catch` below, which returned the GENERIC `{indexed: 0, error: ...}` — discarding the two
403
+ // facts already true by this point: `indexed` rows are on disk, and `bump` already ran. A
404
+ // caller reading `indexed === 0` would conclude "nothing happened" and skip its own rescue-bump
405
+ // logic even though the store had genuinely changed — the under-bump C-1 forbids. Catching the
406
+ // throw HERE, with the real `indexed`/bump outcome already captured in scope, preserves both.
407
+ try {
408
+ writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
409
+ }
410
+ catch (manifestErr) {
411
+ const manifestError = `index failed: ${safeErrorMessage(manifestErr)}`;
412
+ return bump.ok
413
+ ? { indexed, generationBumped: true, error: manifestError }
414
+ : { indexed, generationBumped: false, generationReason: bump.error, error: manifestError };
415
+ }
286
416
  return bump.ok
287
417
  ? { indexed, generationBumped: true }
288
418
  : { indexed, generationBumped: false, generationReason: bump.error };
@@ -292,7 +422,7 @@ export async function indexPatternsToAgentdb(projectRoot, rows, opts = {}) {
292
422
  }
293
423
  }
294
424
  catch (err) {
295
- return { indexed: 0, error: `index failed: ${err instanceof Error ? err.message : String(err)}` };
425
+ return { indexed: 0, error: `index failed: ${safeErrorMessage(err)}` };
296
426
  }
297
427
  }
298
428
  /* ------------------------------------------------------------------ */
@@ -332,18 +462,147 @@ const DEPS_MISSING = 'agentdb/better-sqlite3 not installed in project (run: dz s
332
462
  */
333
463
  const embedderCache = new Map();
334
464
  let embedderCacheInitializations = 0;
465
+ /**
466
+ * Fix round 1 (F6, Codex #6): `embedderCacheInitializations` only counts calls to
467
+ * {@link resolveAgentdbEmbedder} that missed the cache — it proves cache REUSE, not that a real
468
+ * pipeline was actually constructed. This counter increments at the exact two call sites where a
469
+ * pipeline construction primitive actually runs: the direct `pipeline('feature-extraction', …)` call
470
+ * in {@link initAgentdbEmbedder} and `EmbeddingService.initialize()` in
471
+ * {@link initViaAgentdbEmbeddingService} (the COMPAT FALLBACK path) — never merely on entry to
472
+ * `initAgentdbEmbedder`, which can also return an `{error}` (dtype:'q8' with no resolvable
473
+ * transformers, NFR-4) without ever attempting either.
474
+ */
475
+ let embedderCachePipelinesBuilt = 0;
335
476
  /** Test-only (and future warm-start) reset — callers (`vector-tier.ts`, `backlog.ts`) are unaffected. */
336
477
  export function resetAgentdbEmbedderCache() {
337
478
  embedderCache.clear();
338
479
  embedderCacheInitializations = 0;
480
+ embedderCachePipelinesBuilt = 0;
339
481
  }
340
482
  /** `entries` = cached keys right now — a SUCCESSFUL pipeline or an IN-FLIGHT initialization (the promise is
341
483
  * cached before it settles, FR-4; a failed one is evicted, FR-3); `initializations` = pipelines actually
342
- * started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.) */
484
+ * started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.)
485
+ * `pipelinesBuilt` (fix round 1, F6) = the count of REAL pipeline-construction primitives that actually
486
+ * ran (`pipeline()` or `EmbeddingService.initialize()`), never merely the number of times the resolver
487
+ * was entered — see {@link embedderCachePipelinesBuilt}'s own doc comment for why the two can diverge. */
343
488
  export function getAgentdbEmbedderCacheStats() {
344
- return { entries: embedderCache.size, initializations: embedderCacheInitializations };
489
+ return { entries: embedderCache.size, initializations: embedderCacheInitializations, pipelinesBuilt: embedderCachePipelinesBuilt };
490
+ }
491
+ /**
492
+ * Walk UP from `startDir` (inclusive) looking for `<dir>/node_modules/<name>` as a real, existing
493
+ * path — a PLAIN FILESYSTEM CHECK, deliberately never `require.resolve()` alone. MEASURED
494
+ * 2026-09-16: under this package's own vitest harness, `createRequire(join(projectRoot,
495
+ * 'package.json')).resolve('@huggingface/transformers')` succeeds even for a deliberately isolated
496
+ * `/tmp` fixture that installs no such dependency at all (`agentdb-embedder-cache.test.ts`'s AC-3) —
497
+ * the test runner's module loader resolves more liberally than plain Node does, reaching the
498
+ * monorepo's real install regardless of `projectRoot`. `require.resolve` is used only AFTER this
499
+ * filesystem walk has already named a legitimate ancestor, so it can no longer be fooled that way.
500
+ * Returns the ancestor directory whose OWN `node_modules/<name>` exists, or `undefined` if none does
501
+ * all the way to the filesystem root (a handful of synchronous `existsSync` calls either way).
502
+ */
503
+ function findAncestorWithModule(startDir, name) {
504
+ let dir = resolve(startDir);
505
+ for (;;) {
506
+ if (existsSync(join(dir, 'node_modules', name)))
507
+ return dir;
508
+ const parent = dirname(dir);
509
+ if (parent === dir)
510
+ return undefined;
511
+ dir = parent;
512
+ }
513
+ }
514
+ /**
515
+ * C-3 (`embed-daemon-memory`): the SAME resolution order the daemon's own `resolveDeps` uses
516
+ * (`.claude/helpers/dz-embed-daemon.mjs`) — project `package.json` first, then `agentdb`'s own
517
+ * declared dependency (possibly hoisted elsewhere) — so core and the daemon agree on which install
518
+ * of transformers they find, in a monorepo or a plain install alike. Each candidate root is
519
+ * confirmed by {@link findAncestorWithModule} BEFORE `require.resolve` is trusted (see its own doc
520
+ * comment for why the plain try/catch this replaced was not safe under this package's test runner).
521
+ *
522
+ * Exported (fix round 1, F4, same convention as {@link safeErrorMessage}/{@link resetAgentdbEmbedderCache}):
523
+ * `embedder-single-owner.test.ts`'s live-dep skip gate needs the SAME resolution order the production
524
+ * code uses to decide, BEFORE running, whether a live embedder failure is a dependency gap (named skip)
525
+ * or a real defect (must fail) — a text-matching heuristic on the error message cannot tell those apart.
526
+ */
527
+ export function resolveTransformersModule(projectRoot) {
528
+ const candidates = ['@huggingface/transformers', '@xenova/transformers'];
529
+ for (const name of candidates) {
530
+ const ancestor = findAncestorWithModule(projectRoot, name);
531
+ if (ancestor === undefined)
532
+ continue;
533
+ try {
534
+ return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
535
+ }
536
+ catch {
537
+ /* an ancestor that named the directory but whose require still can't resolve it (e.g. a
538
+ broken symlink) — try the next candidate */
539
+ }
540
+ }
541
+ let agentdbDir;
542
+ try {
543
+ agentdbDir = dirname(createRequire(join(projectRoot, 'package.json')).resolve('agentdb'));
544
+ }
545
+ catch {
546
+ return { error: DEPS_MISSING };
547
+ }
548
+ for (const name of candidates) {
549
+ const ancestor = findAncestorWithModule(agentdbDir, name);
550
+ if (ancestor === undefined)
551
+ continue;
552
+ try {
553
+ return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
554
+ }
555
+ catch {
556
+ /* try the next candidate */
557
+ }
558
+ }
559
+ return { error: DEPS_MISSING };
560
+ }
561
+ /**
562
+ * D1 (ADR-001): builds the `transformers` pipeline DIRECTLY — the exact call agentdb's own
563
+ * `EmbeddingService.embed` makes for a symmetric model (`pipeline(text, { pooling:'mean',
564
+ * normalize:true })`, `EmbeddingService.js:205`), never through agentdb's wrapper class. This is
565
+ * the ONLY way to request `dtype:'q8'` at pipeline construction (D2) — `EmbeddingService.initialize`
566
+ * hardcodes `transformers.pipeline('feature-extraction', model)` with no dtype option at all, so a
567
+ * quantized store is unreachable through it at any dtype but fp32.
568
+ *
569
+ * COMPAT FALLBACK (deviation from the ADR's literal "иначе ядро отдаёт {error} как сегодня" —
570
+ * documented in `features/embed-daemon-memory/07_code_changes/change_manifest.md`): when
571
+ * `@huggingface/transformers`/`@xenova/transformers` cannot be resolved directly AND the requested
572
+ * dtype is the default `fp32`, this falls back to agentdb's `EmbeddingService` exactly as this
573
+ * function's pre-T2 body did. Measured (2026-09-16): seven OTHER features' test files
574
+ * (`agentdb-index.test.ts`, `agentdb-snapshot-{consistency,lock,rotation}.test.ts`, `brain.test.ts`,
575
+ * `quarantine-mirror-projection.test.ts`, `store-generation.test.ts`, `vector-tier{,​-rvf}.test.ts`)
576
+ * fake ONLY `agentdb/controllers/EmbeddingService.js` for their offline fixtures, never a
577
+ * `@huggingface/transformers`/`@xenova/transformers` stub — a literal "no fallback" implementation
578
+ * reddens all of them (out of scope here: `.claude/rules/cross-runtime-concurrency.md` and this
579
+ * feature's own hard rule both forbid touching another feature's files). `dtype:'q8'` NEVER falls
580
+ * back (NFR-4) — an unresolvable transformers module with `dtype:'q8'` requested is a hard `{error}`
581
+ * naming the model and dtype, exactly as the ADR specifies; only the fp32 path is widened.
582
+ */
583
+ async function initAgentdbEmbedder(projectRoot, agentdbDir, model, dim, dtype) {
584
+ const transformers = resolveTransformersModule(projectRoot);
585
+ if (!('error' in transformers)) {
586
+ try {
587
+ const { pipeline } = (await import(transformers.url));
588
+ const extractor = await pipeline('feature-extraction', model, dtype === 'q8' ? { dtype: 'q8' } : {});
589
+ embedderCachePipelinesBuilt += 1; // F6: the real primitive ran and returned a usable extractor
590
+ return { embed: async (t) => Float32Array.from((await extractor(t, { pooling: 'mean', normalize: true })).data) };
591
+ }
592
+ catch (err) {
593
+ return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${err instanceof Error ? err.message : String(err)}` };
594
+ }
595
+ }
596
+ if (dtype === 'q8') {
597
+ // NFR-4: a quantized store must never silently downgrade to fp32 for lack of a transformers
598
+ // install — the caller needs to know exactly why q8 is unreachable here.
599
+ return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${transformers.error}` };
600
+ }
601
+ return initViaAgentdbEmbeddingService(agentdbDir, model, dim);
345
602
  }
346
- async function initAgentdbEmbedder(agentdbDir, model, dim) {
603
+ /** The pre-T2 implementation, preserved verbatim as the fp32-only COMPAT FALLBACK documented on
604
+ * {@link initAgentdbEmbedder} above. */
605
+ async function initViaAgentdbEmbeddingService(agentdbDir, model, dim) {
347
606
  try {
348
607
  const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href));
349
608
  const emb = new EmbeddingService({
@@ -360,6 +619,7 @@ async function initAgentdbEmbedder(agentdbDir, model, dim) {
360
619
  rolePolicy: { kind: 'symmetric' },
361
620
  });
362
621
  await emb.initialize();
622
+ embedderCachePipelinesBuilt += 1; // F6: the COMPAT FALLBACK's own primitive ran
363
623
  return { embed: (t) => emb.embed(t) };
364
624
  }
365
625
  catch (err) {
@@ -367,11 +627,45 @@ async function initAgentdbEmbedder(agentdbDir, model, dim) {
367
627
  }
368
628
  }
369
629
  /**
370
- * Resolve agentdb's `EmbeddingService` from the PROJECT (same dynamic-resolution discipline as
371
- * {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model so query and row
372
- * vectors stay in the same space. Cached per process — see {@link embedderCache} above.
630
+ * D2 (`embed-daemon-memory`): the dtype a QUERY/write is embedded with is the STORE's own dtype
631
+ * (its manifest) when the store already exists, falling back to the CONFIGURED dtype only for a
632
+ * store that does not exist yet (its first-ever write picks up the config). This is the ONE place
633
+ * that decision is made — {@link resolveAgentdbEmbedder} calls it so every caller (search, an
634
+ * ordinary incremental index) agrees; `reindexAgentdbRows` is the sole exception (T2/plan): it
635
+ * stamps the manifest with the NEW configured dtype BEFORE it re-embeds, so by the time this
636
+ * function runs during a reindex the manifest already names the new dtype — config and manifest
637
+ * necessarily agree at that point, which is what makes reindex "the one place dtype changes".
373
638
  */
374
- export async function resolveAgentdbEmbedder(projectRoot) {
639
+ export function resolveStoreEmbedDtype(projectRoot, dbPath) {
640
+ const configured = resolveEmbedModel(projectRoot);
641
+ if ('error' in configured)
642
+ return { error: configured.error };
643
+ const manifest = readEmbedManifest(resolveAgentdbPath(projectRoot, dbPath));
644
+ // Fix round 1 (Codex #4): a manifest dtype that is PRESENT but unrecognized must refuse, not fall
645
+ // through to the configured default — the same discipline guardEmbedSpace applies, needed here too
646
+ // because THIS is what resolveAgentdbEmbedder actually keys its cache and pipeline construction on.
647
+ if (manifest?.readError !== undefined) {
648
+ return { error: `embedding manifest unreadable (${manifest.readError}); run dz vector reindex` };
649
+ }
650
+ if (manifest?.dtypeError !== undefined) {
651
+ return { error: `unknown embedding dtype "${manifest.dtypeError}" in manifest; run dz vector reindex` };
652
+ }
653
+ return manifest?.dtype ?? configured.dtype;
654
+ }
655
+ /**
656
+ * Resolve the shared embedder from the PROJECT (same dynamic-resolution discipline as
657
+ * {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model/dtype so query
658
+ * and row vectors stay in the same space. Cached per process — see {@link embedderCache} above.
659
+ *
660
+ * Fix round 1 (F1, doc correction — the prior wording was misleading): `dbPath` is passed straight
661
+ * to {@link resolveStoreEmbedDtype}, which calls {@link resolveAgentdbPath}`(projectRoot, dbPath)` —
662
+ * and THAT function already returns the project's DEFAULT store path (`<project>/.dz/agentdb.db`,
663
+ * or `AGENTDB_PATH`) when `dbPath` is omitted, not "no path". So an omitted `dbPath` still reads the
664
+ * default store's OWN manifest when one exists; the CONFIGURED dtype is used only as the fallback
665
+ * for a store that has no manifest yet (i.e. does not exist, or predates this feature) — never as
666
+ * the default behaviour for "no dbPath given".
667
+ */
668
+ export async function resolveAgentdbEmbedder(projectRoot, dbPath) {
375
669
  let agentdbDir;
376
670
  try {
377
671
  const req = createRequire(join(projectRoot, 'package.json'));
@@ -383,12 +677,15 @@ export async function resolveAgentdbEmbedder(projectRoot) {
383
677
  const model = resolveEmbedModel(projectRoot);
384
678
  if ('error' in model)
385
679
  return { error: model.error };
386
- const key = `${agentdbDir}|${model.model}|${model.dim}`;
680
+ const dtype = resolveStoreEmbedDtype(projectRoot, dbPath);
681
+ if (typeof dtype === 'object' && 'error' in dtype)
682
+ return { error: dtype.error };
683
+ const key = `${agentdbDir}|${model.model}|${model.dim}|${dtype}`;
387
684
  const hit = embedderCache.get(key);
388
685
  if (hit !== undefined)
389
686
  return hit;
390
687
  embedderCacheInitializations += 1;
391
- const promise = initAgentdbEmbedder(agentdbDir, model.model, model.dim);
688
+ const promise = initAgentdbEmbedder(projectRoot, agentdbDir, model.model, model.dim, dtype);
392
689
  embedderCache.set(key, promise);
393
690
  // FR-3: an init failure must not stick — evict so the next call retries instead of replaying
394
691
  // the same {error} forever. `.catch` here only guards a rejection that slips past
@@ -525,7 +822,10 @@ export async function searchAgentdbPatterns(projectRoot, query, opts = {}) {
525
822
  });
526
823
  if (!guard.ok)
527
824
  return { hits: [], error: guard.error };
528
- const emb = await resolveAgentdbEmbedder(projectRoot);
825
+ // D2: the query is embedded with the STORE's own dtype (the guard above already proved the
826
+ // manifest and the config agree) — pass the resolved store path so resolveAgentdbEmbedder reads
827
+ // the same manifest guardEmbedSpace just read, never the config's dtype in isolation.
828
+ const emb = await resolveAgentdbEmbedder(projectRoot, resolveAgentdbPath(projectRoot, opts.dbPath));
529
829
  if ('error' in emb)
530
830
  return { hits: [], error: emb.error };
531
831
  let qvec;
@@ -909,6 +1209,27 @@ export function bumpAgentdbUses(projectRoot, dzIds, opts = {}) {
909
1209
  return { bumped: 0, error: `uses bump failed: ${err instanceof Error ? err.message : String(err)}` };
910
1210
  }
911
1211
  }
1212
+ /**
1213
+ * Fix-round 1 (CRITICAL, item 1b — the belt): whether {@link reindexAgentdbRows} must run its own
1214
+ * rescue bump, given the DELETE's own observed `changes` count and the nested
1215
+ * {@link indexPatternsToAgentdb} call's result. Exported test-only (same convention as
1216
+ * {@link resetAgentdbEmbedderCache}) so the DECISION can be exercised directly and deterministically,
1217
+ * independent of forcing a real concurrent bump-lock race.
1218
+ *
1219
+ * `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined)`:
1220
+ * - `deleteChanges > 0` — the DELETE genuinely removed rows; the store changed regardless of the
1221
+ * nested call's outcome.
1222
+ * - `indexed.indexed > 0` — the nested call committed rows itself but its OWN bump failed
1223
+ * (`generationBumped: false`) or was never attempted.
1224
+ * - `indexed.error !== undefined` — the nested call's post-write state is UNKNOWN (item 1a: an error
1225
+ * here may still carry accurate `indexed`/`generationBumped` facts, but a caller must not assume a
1226
+ * future error path will). C-1: when in doubt, bump — an extra bump only over-invalidates a cache
1227
+ * (safe), a missed one serves stale data (not safe).
1228
+ */
1229
+ export function needsRescueBump(deleteChanges, indexed) {
1230
+ const nestedBumped = indexed.generationBumped === true;
1231
+ return !nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined);
1232
+ }
912
1233
  export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
913
1234
  const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
914
1235
  const ms = Date.now();
@@ -1086,6 +1407,11 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
1086
1407
  }
1087
1408
  };
1088
1409
  let stale = [];
1410
+ // T1 (`store-generation-residuals`, record `3cfcec83`): the OBSERVED fact — `changes` from the
1411
+ // DELETE's own prepared-statement result, never inferred from `rows.length` or any other adjacent
1412
+ // signal (06_implementation_plan.md's T1 section names exactly that inference as the mistake to
1413
+ // avoid). Declared outside the `db` block so it survives to the bump decision below `db.close()`.
1414
+ let deleteChanges = 0;
1089
1415
  try {
1090
1416
  mkdirSync(dirname(dbFile), { recursive: true });
1091
1417
  const db = new Database(dbFile);
@@ -1105,9 +1431,9 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
1105
1431
  const delPat = db.prepare(`DELETE FROM reasoning_patterns WHERE task_type IN (${placeholders})`);
1106
1432
  const tx = db.transaction(() => {
1107
1433
  delEmb.run(...taskTypes);
1108
- delPat.run(...taskTypes);
1434
+ return delPat.run(...taskTypes).changes;
1109
1435
  });
1110
- tx();
1436
+ deleteChanges = tx();
1111
1437
  }
1112
1438
  finally {
1113
1439
  db.close();
@@ -1121,6 +1447,16 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
1121
1447
  writeEmbedManifest(dbFile, currentEmbedManifest(model, version, 'agentdb'));
1122
1448
  const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
1123
1449
  if (indexed.error !== undefined) {
1450
+ // Fix-round 1 (CRITICAL, item 1b — the belt): before this fix, NO bump was attempted anywhere
1451
+ // on this branch, regardless of `deleteChanges` — the DELETE above may have genuinely removed
1452
+ // rows from the store (a real change on disk) and the counter would never move to reflect it,
1453
+ // even though `rollback()` below may itself fail and leave that changed state in place. An
1454
+ // error from the nested call means the post-write state is UNKNOWN — C-1 resolves unknown in
1455
+ // favour of bumping (a spurious extra bump only over-invalidates a cache; a missed one serves
1456
+ // stale data). `needsRescueBump` is the SAME decision used on the success path below — one
1457
+ // rule, not two that could drift apart.
1458
+ if (needsRescueBump(deleteChanges, indexed))
1459
+ bumpStoreGeneration(projectRoot, opts.dbPath);
1124
1460
  const rb = await rollback();
1125
1461
  if (rb.restored === 'failed')
1126
1462
  rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
@@ -1147,13 +1483,36 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
1147
1483
  throw err;
1148
1484
  snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
1149
1485
  }
1150
- // AM-1 (fix-round): `reindexAgentdbRows` is itself a mutator (the DELETE above rebuilds the
1151
- // owned task types) — it must not rely SOLELY on `indexPatternsToAgentdb`'s own internal bump,
1152
- // because that call is a no-op (and bumps nothing) when `rows` is empty, yet the DELETE it ran
1153
- // just above unconditionally changed the store. Bumping again here when `rows` was non-empty
1154
- // (the common case, already bumped once inside `indexPatternsToAgentdb`) is harmless — the
1155
- // counter is a monotonic "did anything change" signal, not a per-operation tally.
1156
- bumpStoreGeneration(projectRoot, opts.dbPath);
1486
+ // T1 (`store-generation-residuals`, record `3cfcec83` — supersedes the AM-1 comment this
1487
+ // replaces, which documented the double-bump as "harmless" rather than fixing it). AM-1's
1488
+ // underlying concern stands unchanged: `reindexAgentdbRows` is ITSELF a mutator (the DELETE
1489
+ // above rebuilds the owned task types) and must not rely solely on `indexPatternsToAgentdb`'s
1490
+ // own internal bump, because that nested call is a no-op — bumps nothing, sets no
1491
+ // `generationBumped` — when `rows` is empty, yet the DELETE just above may have changed the
1492
+ // store regardless of whether there was anything to re-insert.
1493
+ //
1494
+ // The rule (now the shared {@link needsRescueBump} helper — fix-round 1, item 1b — used
1495
+ // identically on the error branch above) uses OBSERVED facts, never inferred from an adjacent
1496
+ // signal (the mistake named in the plan's T1 section, fresh from the worker-ceiling fix that
1497
+ // predates this one): `deleteChanges` is the DELETE's own `changes` count, read directly off the
1498
+ // prepared-statement result; `indexed.generationBumped` is a field `indexPatternsToAgentdb` sets
1499
+ // ONLY where its own bump actually ran (never guessed from `indexed.indexed > 0`, which is
1500
+ // itself a real fact but a DIFFERENT one — see below).
1501
+ //
1502
+ // The condition is intentionally `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 ||
1503
+ // indexed.error !== undefined)`, NOT the narrower `deleteChanges > 0 && !nestedBumped` the
1504
+ // plan's prose formula reads as: a bare `deleteChanges > 0` gate would MISS the case where the
1505
+ // DELETE removed nothing (a first-ever reindex of these task types) but the nested insert then
1506
+ // ran and its OWN bump failed (`indexed.generationBumped === false`, e.g. a transient lock
1507
+ // timeout) — under the narrower gate the store would have changed on disk with no rescue bump at
1508
+ // all, a genuine under-bump. C-1 (`01_requirements.md`) makes correctness here non-negotiable:
1509
+ // "при сомнении поднимать счётчик ЛИШНИЙ раз безопаснее, чем не поднять" — so the OR-of-facts
1510
+ // form below is what actually ships; it satisfies every case FR-1's AC-1 enumerates AND closes
1511
+ // the gaps the plan's literal formula and the pre-fix-round-1 condition left open, verified by
1512
+ // exhaustive case analysis in `features/store-generation-residuals/07_code_changes/change_manifest.md`.
1513
+ if (needsRescueBump(deleteChanges, indexed)) {
1514
+ bumpStoreGeneration(projectRoot, opts.dbPath);
1515
+ }
1157
1516
  return {
1158
1517
  reembedded: indexed.indexed,
1159
1518
  model: model.model,