@dzhechkov/harness-core 0.8.33 → 0.8.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.dz-manifest.json +52 -52
  2. package/README.md +228 -5
  3. package/dist/agentdb-index.d.ts +39 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +217 -23
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +197 -6
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +858 -46
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/index.d.ts +7 -6
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +9 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/mutation-gate.d.ts +19 -0
  16. package/dist/mutation-gate.d.ts.map +1 -1
  17. package/dist/mutation-gate.js +37 -1
  18. package/dist/mutation-gate.js.map +1 -1
  19. package/dist/operations.d.ts +16 -1
  20. package/dist/operations.d.ts.map +1 -1
  21. package/dist/operations.js +115 -13
  22. package/dist/operations.js.map +1 -1
  23. package/dist/publish-sibling-drift.d.ts +72 -0
  24. package/dist/publish-sibling-drift.d.ts.map +1 -1
  25. package/dist/publish-sibling-drift.js +150 -4
  26. package/dist/publish-sibling-drift.js.map +1 -1
  27. package/dist/release.d.ts +72 -0
  28. package/dist/release.d.ts.map +1 -1
  29. package/dist/release.js +236 -19
  30. package/dist/release.js.map +1 -1
  31. package/dist/setup.d.ts.map +1 -1
  32. package/dist/setup.js +90 -14
  33. package/dist/setup.js.map +1 -1
  34. package/dist/skills.d.ts +87 -3
  35. package/dist/skills.d.ts.map +1 -1
  36. package/dist/skills.js +266 -15
  37. package/dist/skills.js.map +1 -1
  38. package/dist/vector-tier.d.ts +27 -2
  39. package/dist/vector-tier.d.ts.map +1 -1
  40. package/dist/vector-tier.js +117 -4
  41. package/dist/vector-tier.js.map +1 -1
  42. package/package.json +2 -2
  43. package/sbom.json +51 -51
  44. package/src/agentdb-index.ts +223 -24
  45. package/src/apply-leg.ts +875 -46
  46. package/src/index.ts +18 -2
  47. package/src/mutation-gate.ts +58 -2
  48. package/src/operations.ts +117 -14
  49. package/src/publish-sibling-drift.ts +209 -4
  50. package/src/release.ts +263 -17
  51. package/src/setup.ts +81 -16
  52. package/src/skills.ts +303 -14
  53. package/src/vector-tier.ts +157 -5
@@ -12,7 +12,7 @@
12
12
  * @packageDocumentation
13
13
  */
14
14
 
15
- import { existsSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs';
15
+ import { existsSync, mkdirSync, copyFileSync, realpathSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
16
16
  import { join, dirname, resolve, relative, isAbsolute, basename } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { createRequire } from 'node:module';
@@ -23,7 +23,7 @@ import { applyReadonlyPragmas } from './sqlite-read-helpers.js';
23
23
  import { rotatePreReindexSnapshotsUnlocked, type SnapshotRotationReport } from './agentdb-snapshot-rotation.js';
24
24
  import { snapshotSqliteDatabase, restoreSqliteSnapshot, type SnapshotMethod, type SnapshotDbCtor } from './agentdb-snapshot.js';
25
25
  import { withAgentdbSnapshotLock, writeReindexMarker, clearReindexMarker, markReindexMarkerRecoveryRequired, reindexMarkerPath, msFromBackupPath } from './agentdb-reindex-marker.js';
26
- import { NamedLockTimeoutError } from './named-lock.js';
26
+ import { NamedLockTimeoutError, withNamedLockSync } from './named-lock.js';
27
27
  import type { StoreLockOptions } from './store-lock.js';
28
28
  // The backlog dedup embed form (PURE, zero-dep — no cycle): dz-backlog rows must be embedded in the
29
29
  // SAME bounded form the dedup query uses, including through the reindex path.
@@ -48,10 +48,14 @@ export interface AgentdbRow {
48
48
  readonly avgReward?: number;
49
49
  }
50
50
 
51
- /** Outcome of {@link indexPatternsToAgentdb}. */
51
+ /** Outcome of {@link indexPatternsToAgentdb}. `generationBumped`/`generationReason` are present only
52
+ * 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. */
52
54
  export interface AgentdbIndexResult {
53
55
  readonly indexed: number;
54
56
  readonly error?: string | undefined;
57
+ readonly generationBumped?: boolean;
58
+ readonly generationReason?: string;
55
59
  }
56
60
 
57
61
  interface NativeDb {
@@ -135,6 +139,112 @@ export function ensureAgentdbSchema(projectRoot: string, dbPath?: string): { rea
135
139
  }
136
140
  }
137
141
 
142
+ /** `<dbFile>.generation` — a sidecar counter next to the store itself, so it travels with any copy
143
+ * of `.dz/agentdb.db` (backup, mirror sync) without a separate path to keep in sync. */
144
+ function generationFilePath(dbFile: string): string {
145
+ return `${dbFile}.generation`;
146
+ }
147
+
148
+ /** AM-5 (fix-round): the ONLY shape {@link readStoreGeneration} trusts — one or more ASCII digits,
149
+ * nothing else. `Number.parseInt` alone accepts a leading-numeric-with-trailing-junk string like
150
+ * `"12junk"` as `12`; that reads a corrupt sidecar as a plausible generation instead of degrading to
151
+ * the documented `0` compatibility floor. */
152
+ const STRICT_GENERATION = /^\d+$/;
153
+
154
+ /**
155
+ * FR-2/FR-3 (`store-generation-counter`): the store's write-generation counter, read back. A
156
+ * missing file (a store that predates this feature, or one that has never been written through
157
+ * {@link bumpStoreGeneration}) reads as `0` — the compatibility floor {@link getOrOpenEngine}'s
158
+ * caller compares against, never an error. A corrupt/non-numeric file degrades the same way (best
159
+ * effort — a bad counter must never crash a read path), never a throw. AM-5: the content must match
160
+ * {@link STRICT_GENERATION} exactly — `Number.parseInt`'s leading-digits-only tolerance is NOT used
161
+ * to decide validity, only to convert an already-validated string.
162
+ */
163
+ 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
+ }
172
+ }
173
+
174
+ /**
175
+ * FR-1 (`store-generation-counter`): monotonically advance the store's write-generation counter by
176
+ * one (absent file ⇒ starts at 1), atomically (tmp + rename — the same durability discipline every
177
+ * other sidecar file in this module uses; a reader can never observe a half-written counter) AND
178
+ * under mutual exclusion (AM-2, fix-round after Codex review). A bare read→compute→rename with no
179
+ * lock lets two concurrent writers both read the same current value and both publish `current+1` —
180
+ * one bump is lost — or lets a DELAYED writer overwrite a later value with an earlier one (the
181
+ * counter briefly goes backwards on disk). `withNamedLockSync` (`named-lock.ts`, the repo's
182
+ * advisory lock for a read-modify-write file store — `.claude/rules/cross-runtime-concurrency.md`)
183
+ * serializes the critical section; the counter is RE-READ from disk *inside* the lock (never trusted
184
+ * from before acquisition), so the sequence every process observes is strictly monotonic
185
+ * (`agentdb-reindex-marker.ts`'s `withAgentdbSnapshotLock` is the precedent this mirrors — a lock
186
+ * addressed by `dirname(dbFile)`, a pure function of the store's own directory, never of
187
+ * `process.cwd()`).
188
+ *
189
+ * NEVER throws (FR-4): a write failure (read-only `.dz`, a full disk, a permissions error, an
190
+ * unresolvable path, OR a lock that could not be acquired by its deadline) is reported honestly as
191
+ * `{ok:false, error}` so the caller can log it — the store write it accompanies must stay successful
192
+ * regardless, telemetry is never a gate. AM-4: {@link resolveAgentdbPath} itself now runs INSIDE this
193
+ * function's outer `try` — an unresolvable path can no longer throw OUT of `bumpStoreGeneration`
194
+ * either; "never throws" now covers the whole function, not just the file-write tail.
195
+ */
196
+ /** Codex round-2 (NEW HIGH): most mutators discard the bump result, so a failed bump must be
197
+ * VISIBLE on its own — one stderr line, written by the helper itself. Telemetry never throws. */
198
+ function reportBumpFailure(error: string): { readonly ok: false; readonly error: string } {
199
+ try { process.stderr.write(`dz: store generation not bumped — ${error}\n`); } catch { /* telemetry never throws */ }
200
+ return { ok: false, error };
201
+ }
202
+
203
+ export function bumpStoreGeneration(
204
+ projectRoot: string,
205
+ dbPath?: string,
206
+ ): { readonly ok: true; readonly generation: number } | { readonly ok: false; readonly error: string } {
207
+ try {
208
+ const dbFile = resolveAgentdbPath(projectRoot, dbPath);
209
+ const genFile = generationFilePath(dbFile);
210
+ return withNamedLockSync(
211
+ dirname(dbFile),
212
+ 'store-generation',
213
+ (): { readonly ok: true; readonly generation: number } | { readonly ok: false; readonly error: string } => {
214
+ // AM-2: re-read the CURRENT value from disk while holding the lock — a value observed before
215
+ // acquisition may already be stale, another holder may have advanced it in the meantime.
216
+ let current = readStoreGeneration(projectRoot, dbPath);
217
+ const tmp = `${genFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
218
+ 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.
223
+ if (current === 0 && existsSync(genFile)) {
224
+ const raw = readFileSync(genFile, 'utf8').trim();
225
+ if (raw !== '0' && !STRICT_GENERATION.test(raw)) current = Date.now();
226
+ }
227
+ const next = current + 1;
228
+ mkdirSync(dirname(genFile), { recursive: true });
229
+ writeFileSync(tmp, String(next), { encoding: 'utf8', flag: 'wx' });
230
+ renameSync(tmp, genFile);
231
+ return { ok: true, generation: next };
232
+ } catch (err) {
233
+ // Best-effort cleanup of a half-written temp file (e.g. rename failed after a successful
234
+ // write) so it never lingers as clutter — never lets a cleanup failure mask the real error.
235
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best-effort only */ }
236
+ return reportBumpFailure(`store generation bump failed: ${err instanceof Error ? err.message : String(err)}`);
237
+ }
238
+ },
239
+ );
240
+ } catch (err) {
241
+ // AM-2/FR-4: a lock that could not be acquired by its deadline (`NamedLockTimeoutError`) — and
242
+ // any other failure reaching this point (an unresolvable path, AM-4) — degrades to the same
243
+ // 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)}`);
245
+ }
246
+ }
247
+
138
248
  /**
139
249
  * Index `rows` into the shared AgentDB vector store. Returns `{indexed:0}` for an empty input and
140
250
  * `{indexed:0, error}` when `agentdb`/`better-sqlite3` cannot be resolved from the project.
@@ -218,8 +328,19 @@ export async function indexPatternsToAgentdb(
218
328
  return prepared.length;
219
329
  });
220
330
  const indexed = commit() as number;
331
+ // AM-3 (fix-round): bump the generation IMMEDIATELY after the commit — BEFORE
332
+ // `writeEmbedManifest` — not after it. The store already changed on disk the instant `commit()`
333
+ // returned; if the manifest write throws (a jammed manifest path, a full disk), the OLD order
334
+ // left the DB changed with no generation ever published — a stale-cache read would then serve
335
+ // an engine that never saw this write, with no signal anywhere that anything went wrong. Moving
336
+ // the bump here makes "the store changed" and "the generation reflects it" atomic in effect:
337
+ // whichever of the two calls below throws, the generation is already correct for the rows that
338
+ // are already on disk.
339
+ const bump = bumpStoreGeneration(projectRoot, opts.dbPath);
221
340
  writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
222
- return { indexed };
341
+ return bump.ok
342
+ ? { indexed, generationBumped: true }
343
+ : { indexed, generationBumped: false, generationReason: bump.error };
223
344
  } finally {
224
345
  db.close();
225
346
  }
@@ -284,30 +405,43 @@ export interface AgentdbSearchResult {
284
405
 
285
406
  const DEPS_MISSING = 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)';
286
407
 
408
+ type Embedder = { embed: (t: string) => Promise<Float32Array> } | { error: string };
409
+
287
410
  /**
288
- * Resolve agentdb's `EmbeddingService` from the PROJECT (same dynamic-resolution discipline as
289
- * {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model so query and row
290
- * vectors stay in the same space.
411
+ * `agentdb-embedder-cache` (feature): the transformers pipeline behind `EmbeddingService` is
412
+ * expensive to stand up (MEASURED 2026-09-14: 2-3.6s per `resolveAgentdbEmbedder` call — see
413
+ * `features/agentdb-embedder-cache/00_complexity_assessment.md`), yet the resolved model/dim never
414
+ * changes within one process. Keyed by `${agentdbDir}|${model}|${dim}` so a config/env change (a
415
+ * different `resolveEmbedModel` source) gets its own entry (FR-2) rather than reusing a stale
416
+ * pipeline. The PROMISE is cached, not the awaited result (FR-4): concurrent first callers for the
417
+ * same key join the same in-flight initialization instead of racing two pipelines. An `{error}`
418
+ * outcome (or a rejection) evicts its own entry so the next call retries cleanly (FR-3) — a failure
419
+ * must never "stick".
291
420
  */
292
- export async function resolveAgentdbEmbedder(
293
- projectRoot: string,
294
- ): Promise<{ embed: (t: string) => Promise<Float32Array> } | { error: string }> {
295
- let agentdbDir: string;
296
- try {
297
- const req = createRequire(join(projectRoot, 'package.json'));
298
- agentdbDir = dirname(req.resolve('agentdb'));
299
- } catch {
300
- return { error: DEPS_MISSING };
301
- }
421
+ const embedderCache = new Map<string, Promise<Embedder>>();
422
+ let embedderCacheInitializations = 0;
423
+
424
+ /** Test-only (and future warm-start) reset — callers (`vector-tier.ts`, `backlog.ts`) are unaffected. */
425
+ export function resetAgentdbEmbedderCache(): void {
426
+ embedderCache.clear();
427
+ embedderCacheInitializations = 0;
428
+ }
429
+
430
+ /** `entries` = cached keys right now — a SUCCESSFUL pipeline or an IN-FLIGHT initialization (the promise is
431
+ * 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 };
435
+ }
436
+
437
+ async function initAgentdbEmbedder(agentdbDir: string, model: string, dim: number): Promise<Embedder> {
302
438
  try {
303
439
  const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
304
440
  EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
305
441
  };
306
- const model = resolveEmbedModel(projectRoot);
307
- if ('error' in model) return { error: model.error };
308
442
  const emb = new EmbeddingService({
309
- model: model.model,
310
- dimension: model.dim,
443
+ model,
444
+ dimension: dim,
311
445
  provider: 'transformers',
312
446
  // agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
313
447
  // (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
@@ -325,6 +459,41 @@ export async function resolveAgentdbEmbedder(
325
459
  }
326
460
  }
327
461
 
462
+ /**
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.
466
+ */
467
+ export async function resolveAgentdbEmbedder(projectRoot: string): Promise<Embedder> {
468
+ let agentdbDir: string;
469
+ try {
470
+ const req = createRequire(join(projectRoot, 'package.json'));
471
+ agentdbDir = dirname(req.resolve('agentdb'));
472
+ } catch {
473
+ return { error: DEPS_MISSING };
474
+ }
475
+ const model = resolveEmbedModel(projectRoot);
476
+ if ('error' in model) return { error: model.error };
477
+ const key = `${agentdbDir}|${model.model}|${model.dim}`;
478
+ const hit = embedderCache.get(key);
479
+ if (hit !== undefined) return hit;
480
+ embedderCacheInitializations += 1;
481
+ const promise = initAgentdbEmbedder(agentdbDir, model.model, model.dim);
482
+ embedderCache.set(key, promise);
483
+ // FR-3: an init failure must not stick — evict so the next call retries instead of replaying
484
+ // the same {error} forever. `.catch` here only guards a rejection that slips past
485
+ // `initAgentdbEmbedder`'s own try/catch; it never rethrows (this is bookkeeping, not the return path).
486
+ void promise.then(
487
+ (result) => {
488
+ if ('error' in result && embedderCache.get(key) === promise) embedderCache.delete(key);
489
+ },
490
+ () => {
491
+ if (embedderCache.get(key) === promise) embedderCache.delete(key);
492
+ },
493
+ );
494
+ return promise;
495
+ }
496
+
328
497
  /** `absent: true` = no store file yet — an EMPTY mirror is a state, not an error (backfill relies on this). */
329
498
  function openReadonly(projectRoot: string, dbPath?: string): { db: ReadonlyDb } | { absent: true } | { error: string } {
330
499
  const dbFile = resolveAgentdbPath(projectRoot, dbPath);
@@ -678,6 +847,12 @@ export async function importVectorsToAgentdb(
678
847
  return imported;
679
848
  });
680
849
  const imported = commit();
850
+ // AM-1 (fix-round): `importVectorsToAgentdb` is the write-half of `dz vector import` — it
851
+ // changes `pattern_embeddings`/`reasoning_patterns` exactly like `indexPatternsToAgentdb`, so
852
+ // it must bump the SAME counter (the whole point of a single "did the store change" signal is
853
+ // that every writer feeds it, not just one). Ordered before `writeEmbedManifest`, same AM-3
854
+ // rationale: the rows are already on disk by the time `commit()` returns.
855
+ bumpStoreGeneration(projectRoot, opts.dbPath);
681
856
  writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
682
857
  return { imported };
683
858
  } finally {
@@ -725,7 +900,14 @@ export function clearAgentdbQuarantine(
725
900
  }
726
901
  return cleared;
727
902
  });
728
- return { cleared: tx() };
903
+ const cleared = tx();
904
+ // AM-1 (fix-round): a quarantine clear mutates `metadata` on rows the hook daemon reads
905
+ // straight from this mirror (see the doc comment above) — it changes what a query returns, so
906
+ // it must bump too, exactly like every other mutator. Only when something actually changed
907
+ // (`cleared > 0`) — same "no write, no bump" discipline as `indexPatternsToAgentdb`'s
908
+ // empty-rows case (AC-2).
909
+ if (cleared > 0) bumpStoreGeneration(projectRoot, opts.dbPath);
910
+ return { cleared };
729
911
  } finally {
730
912
  db.close();
731
913
  }
@@ -781,7 +963,11 @@ export function deleteAgentdbByDzIds(
781
963
  }
782
964
  return deleted;
783
965
  });
784
- return { deleted: tx() };
966
+ const deleted = tx();
967
+ // AM-1 (fix-round): a DELETE removes rows from a query's result set exactly as surely as an
968
+ // INSERT adds them — it must bump too. Only when rows actually left the store (`deleted > 0`).
969
+ if (deleted > 0) bumpStoreGeneration(projectRoot, opts.dbPath);
970
+ return { deleted };
785
971
  } finally {
786
972
  db.close();
787
973
  }
@@ -826,7 +1012,13 @@ export function bumpAgentdbUses(
826
1012
  }
827
1013
  return bumped;
828
1014
  });
829
- return { bumped: tx() };
1015
+ const bumped = tx();
1016
+ // AM-1 (fix-round): `uses`/`avg_reward` feed reward-weighted ranking — a change here is a real
1017
+ // store mutation, so it bumps the SAME counter (deliberately NOT named `bump` — that identifier
1018
+ // is this function's own return value; the counter helper is called by its full name below to
1019
+ // avoid the collision). Only when a row actually changed (`bumped > 0`).
1020
+ if (bumped > 0) bumpStoreGeneration(projectRoot, opts.dbPath);
1021
+ return { bumped };
830
1022
  } finally {
831
1023
  db.close();
832
1024
  }
@@ -1109,6 +1301,13 @@ export async function reindexAgentdbRows(
1109
1301
  if (!(err instanceof NamedLockTimeoutError)) throw err;
1110
1302
  snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
1111
1303
  }
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);
1112
1311
  return {
1113
1312
  reembedded: indexed.indexed,
1114
1313
  model: model.model,