@dzhechkov/harness-core 0.6.1 → 0.7.0

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 (44) hide show
  1. package/.dz-manifest.json +44 -780
  2. package/README.md +37 -1
  3. package/dist/agentdb-index.d.ts +11 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +8 -1
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/guard.d.ts +29 -0
  8. package/dist/guard.d.ts.map +1 -1
  9. package/dist/guard.js +54 -0
  10. package/dist/guard.js.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/publish-signing.d.ts +113 -0
  16. package/dist/publish-signing.d.ts.map +1 -0
  17. package/dist/publish-signing.js +124 -0
  18. package/dist/publish-signing.js.map +1 -0
  19. package/dist/publish.d.ts +21 -0
  20. package/dist/publish.d.ts.map +1 -1
  21. package/dist/publish.js +106 -0
  22. package/dist/publish.js.map +1 -1
  23. package/dist/registry.d.ts +19 -0
  24. package/dist/registry.d.ts.map +1 -1
  25. package/dist/registry.js +91 -1
  26. package/dist/registry.js.map +1 -1
  27. package/dist/sign.d.ts +24 -2
  28. package/dist/sign.d.ts.map +1 -1
  29. package/dist/sign.js +34 -1
  30. package/dist/sign.js.map +1 -1
  31. package/dist/vector-tier.d.ts +81 -0
  32. package/dist/vector-tier.d.ts.map +1 -1
  33. package/dist/vector-tier.js +137 -10
  34. package/dist/vector-tier.js.map +1 -1
  35. package/package.json +6 -6
  36. package/sbom.json +73 -1913
  37. package/src/agentdb-index.ts +8 -1
  38. package/src/guard.ts +74 -0
  39. package/src/index.ts +3 -1
  40. package/src/publish-signing.ts +217 -0
  41. package/src/publish.ts +102 -1
  42. package/src/registry.ts +84 -1
  43. package/src/sign.ts +33 -1
  44. package/src/vector-tier.ts +202 -12
package/src/sign.ts CHANGED
@@ -246,6 +246,19 @@ export function verifyManifest(
246
246
  root: string,
247
247
  signed: SignedManifest | null | undefined,
248
248
  pubKeyPem: string,
249
+ /**
250
+ * The paths the pack actually SHIPS, when the caller can establish them (from `npm pack`). The
251
+ * added-file sweep is then scoped to those, because a working-tree file `files[]` excludes was
252
+ * never "added to the pack" — it simply is not part of it.
253
+ *
254
+ * Omit it and the sweep covers the whole tree, exactly as before. That is correct for the case that
255
+ * matters most: a consumer verifying an EXTRACTED tarball, where the tree IS the shipped set.
256
+ *
257
+ * This parameter exists because the signer and the sweep must never disagree about what a pack
258
+ * contains — the 10-false-TAMPERED lesson, recorded at `packFiles` and re-learned on 2026-08-21 the
259
+ * moment the signer started scoping and the sweep did not.
260
+ */
261
+ shippedPaths?: readonly string[],
249
262
  ): VerifyResult {
250
263
  const fail = (path: string, reason: string): VerifyResult => ({ ok: false, failures: [{ path, reason }] });
251
264
 
@@ -300,10 +313,14 @@ export function verifyManifest(
300
313
  }
301
314
 
302
315
  // Bidirectional, always: hashing only what the manifest lists lets an attacker ADD a file.
316
+ const shipped = shippedPaths === undefined ? null : new Set(shippedPaths);
303
317
  const present = listPackFiles(root);
304
318
  const listed = new Set(manifest.files.map((f) => f.path));
305
319
  for (const rel of present) {
306
320
  const p = rel.split(sep).join('/');
321
+ // A file the pack does not ship is not an ADDED file; scoping here is what keeps the sweep and the
322
+ // signer describing the same object.
323
+ if (shipped !== null && !shipped.has(p)) continue;
307
324
  if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
308
325
  }
309
326
 
@@ -415,7 +432,7 @@ export function decidePublishGate(input: PublishGateInput): PublishGateDecision
415
432
  // A verifier nobody runs is a signature nobody checks. These two pure functions carry the whole
416
433
  // security content of `dz doctor` / `dz upgrade`; the CLI bodies only print and exit.
417
434
 
418
- export type PackVerdict = 'verified' | 'unsigned' | 'tampered' | 'no-trust-root';
435
+ export type PackVerdict = 'verified' | 'unsigned' | 'tampered' | 'no-trust-root' | 'source-tree';
419
436
 
420
437
  export interface TrustRootCandidates {
421
438
  /** `--pubkey <path>`, if the caller passed one and it exists. */
@@ -458,11 +475,26 @@ export interface PolicyDecision {
458
475
  * | unsigned | report | fail |
459
476
  * | tampered | FAIL | FAIL |
460
477
  * | no-trust-root | report | fail |
478
+ * | source-tree | report | report |
479
+ *
480
+ * `source-tree` was added on 2026-08-21, when the manifest began describing the PUBLISHED TARBALL
481
+ * rather than the working tree. Those are two different objects — `pnpm publish` re-serialises
482
+ * package.json and rewrites `workspace:*` — so a source checkout CANNOT be hash-verified against a
483
+ * tarball-scoped manifest, and calling that TAMPERED is a false alarm. It is not `verified` either:
484
+ * nothing was established. It stays `report` even under `--require-signing`, because the honest
485
+ * remedy is to verify the artifact (`dz verify-pack` packs and checks the tarball), not to fail a
486
+ * developer's checkout for being a checkout.
461
487
  *
462
488
  * `tampered` is fatal in both columns; `no-trust-root` is never success. That is the load-bearing
463
489
  * property. Today every pack is `unsigned` or `no-trust-root` — the leg is wired, not armed.
464
490
  */
465
491
  export function decideVerifyPolicy(verdict: PackVerdict, requireSigning: boolean): PolicyDecision {
492
+ if (verdict === 'source-tree') {
493
+ return {
494
+ action: 'report',
495
+ reason: 'source checkout — its manifest describes the published tarball, which this tree is not; run `dz verify-pack` to check the artifact',
496
+ };
497
+ }
466
498
  if (verdict === 'tampered') {
467
499
  return { action: 'fail', reason: 'pack does not match its signed manifest' };
468
500
  }
@@ -61,6 +61,7 @@ import {
61
61
  indexPatternsToAgentdb,
62
62
  searchAgentdbPatterns,
63
63
  listAgentdbDzIds,
64
+ DZ_PATTERN_TASK_TYPES,
64
65
  resolveAgentdbEmbedder,
65
66
  cosineSimilarity,
66
67
  importVectorsToAgentdb,
@@ -137,6 +138,14 @@ export interface VectorEngine {
137
138
  upsert(entries: readonly VectorEntry[]): Promise<{ indexed: number; error?: string | undefined }>;
138
139
  search(query: string, limit: number): Promise<{ hits: VectorHit[]; error?: string | undefined }>;
139
140
  listIds(): Promise<{ ids: string[]; error?: string | undefined }>;
141
+ /**
142
+ * Ids of the PATTERN scope only — the task types `lexicalMirrorable` counts. Optional: an engine
143
+ * that cannot narrow reports nothing and the caller degrades to `orphaned: undefined`, never to a
144
+ * fabricated zero. Exists because `listIds()` deliberately enumerates the OWNED SUPERSET, which
145
+ * also holds `dz-backlog` ideas — and reporting that number beside a pattern-only count once led a
146
+ * reader to conclude half the index was orphaned when none of it was (ADR-001).
147
+ */
148
+ listPatternIds?(): Promise<{ ids: string[]; error?: string | undefined }>;
140
149
  /** Portable single-file checkpoint (RVF adapter only — `dz vector export`). */
141
150
  exportCheckpoint?(dest: string): Promise<{ error?: string | undefined }>;
142
151
  /**
@@ -175,6 +184,39 @@ export interface HybridRecall {
175
184
  readonly vectorReason?: string | undefined;
176
185
  /** Engine was present but the search failed/timed out — lexical results returned instead. */
177
186
  readonly vectorError?: string | undefined;
187
+ /**
188
+ * How many candidates the engine RETURNED for this query. Zero on every lexical-only path.
189
+ *
190
+ * This and `semanticRanked` exist so the OUTPUT can state what the run did instead of what the
191
+ * config allows. Every false claim this feature removed came from deriving a statement about the
192
+ * run from `vectorEngine !== 'none'`, which is engine RESOLVABILITY (ADR-001).
193
+ */
194
+ readonly semanticCandidates: number;
195
+ /**
196
+ * How many of those candidates survived the orphan drop and entered the merge — the honest test of
197
+ * "did a vector rank anything". It can be 0 while `semanticCandidates` is positive: an engine that
198
+ * returns only ids the lexical store no longer has has participated in nothing.
199
+ */
200
+ readonly semanticRanked: number;
201
+ }
202
+
203
+
204
+ /**
205
+ * How many of `mirrorable` are absent from the mirror, given the set of ids the mirror and the
206
+ * pending queue account for.
207
+ *
208
+ * A record counts as mirrored under EITHER key, because there are two write seams and they do NOT
209
+ * agree: `patternVectorEntry` (teach) writes the CONTENT-addressed `patternRecordId`, while
210
+ * `memoryRecordVectorEntry` (backfill/dream) writes the record's own `r.id`. MEASURED on the real
211
+ * store (2026-08-22): 270 of 272 records have the same value for both, and the 2 that differ — a
212
+ * `dream:` record and one re-keyed teach record — are mirrored under their OWN id. Joining on the
213
+ * derived key alone reported them as an unpaid debt and advised a reindex with nothing to do.
214
+ *
215
+ * Pure and exported so the divergent case is testable without a store: a `dream:`-prefixed id can
216
+ * never equal `patternRecordId`, which always yields `teach:<hash>`.
217
+ */
218
+ export function countUnmirrored(mirrorable: readonly MemoryRecord[], accounted: ReadonlySet<string>): number {
219
+ return mirrorable.filter((r) => !accounted.has(r.id) && !accounted.has(patternRecordId(recordToPattern(r)))).length;
178
220
  }
179
221
 
180
222
  /** Field observability for `dz vector status` (I-2/I-5 in the field). */
@@ -186,8 +228,44 @@ export interface VectorTierStatus {
186
228
  readonly embeddingModel?: string | undefined;
187
229
  readonly lexicalTotal: number;
188
230
  readonly lexicalMirrorable: number;
231
+ /**
232
+ * How many MIRRORABLE RECORDS are in the vector store, counted in the same scope
233
+ * `lexicalMirrorable` counts — so the two may be read as a pair.
234
+ *
235
+ * `undefined` when the engine cannot report per-scope counts: under the narrower meaning this
236
+ * field now carries, the unnarrowed store total is NOT an answer to the question it asks.
237
+ */
189
238
  readonly mirrored?: number | undefined;
190
239
  readonly pending: number;
240
+ /**
241
+ * Whether the mirror WRITER is enabled — `vectorMirrorEnabled()`'s own answer, reported separately
242
+ * from `available`, which is about the ENGINE. Conflating the two is what let an unconfigured
243
+ * project print a fully-healthy status line over a dead writer (ADR-001).
244
+ */
245
+ readonly mirrorWriterEnabled: boolean;
246
+ /**
247
+ * Vectors of OTHER dz-owned task types (today: `dz-backlog`). Reported separately so a store of N
248
+ * vectors can be fully accounted for, and never folded into `mirrored`, which must stay comparable
249
+ * to `lexicalMirrorable`. `undefined` when the engine cannot narrow its listing.
250
+ */
251
+ readonly mirroredOther?: number | undefined;
252
+ /**
253
+ * Pattern-scope vectors matching NO record under either key — the real meaning of "orphan", and
254
+ * until this feature nothing computed it. `undefined` when the engine cannot narrow its listing.
255
+ */
256
+ readonly orphaned?: number | undefined;
257
+ /**
258
+ * Mirrorable records present in NEITHER the mirror NOR the pending queue — an exact set difference
259
+ * over ids, so an orphan vector can never make it negative.
260
+ *
261
+ * The name is deliberately `unmirrored`, not `unqueued`: the difference proves only that a record
262
+ * is not in the mirror NOW. A vector that was written and later deleted, or a pending entry that
263
+ * was cleared, is indistinguishable from one never offered — so claiming "never queued" would
264
+ * overstate what was measured (raised by cross-family review, 2026-08-22).
265
+ *
266
+ * `undefined` means UNKNOWN — no engine, or an engine whose `listIds()` failed. It never means zero.
267
+ */
268
+ readonly unmirrored?: number | undefined;
191
269
  }
192
270
 
193
271
  /** Wall-time bound applied to EVERY engine call, read and write legs alike (ADR R1 + NC1). */
@@ -735,6 +813,11 @@ const RRF_K = 60;
735
813
  /**
736
814
  * Reciprocal Rank Fusion merge: `score(p) = Σ 1/(60 + rank)` over the lists containing `p`
737
815
  * (semantic ranks weighted by `semanticWeight`). Dedup by id; `backend: 'both'` when a pattern
816
+ *
817
+ * Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id.
818
+ * When `semanticWeight > 1` the lexical top-1 is guaranteed a place in the result, taken from the
819
+ * last seat unless that seat holds a `both` hit. See `features/semantic-keeps-exact-hits`.
820
+ *
738
821
  * appears in both lists. DETERMINISTIC (AC-6): ties break on id, so fixed inputs always yield
739
822
  * the same ordering. Pure — no I/O.
740
823
  */
@@ -758,14 +841,59 @@ export function mergeHybridHits(
758
841
  cur.score += weight / (RRF_K + rank + 1);
759
842
  acc.set(h.id, cur);
760
843
  });
761
- return [...acc.entries()]
762
- .sort((a, b) => b[1].score - a[1].score || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
763
- .slice(0, opts.limit)
764
- .map(([, v]) => ({
765
- pattern: v.pattern,
766
- backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
767
- score: v.score,
768
- }));
844
+ // Ties break by EVIDENCE, not by the id alphabet: a hit both legs found outranks one only a single
845
+ // leg found. Before this, an exact-term match lost a tie to an arbitrary semantic hit purely
846
+ // because its id sorted later (ADR-001 AM-4).
847
+ const evidence = (v: Acc): number => (v.lex !== undefined && v.sem ? 0 : v.lex !== undefined ? 1 : 2);
848
+ const ordered = [...acc.entries()]
849
+ .sort((a, b) => b[1].score - a[1].score
850
+ || evidence(a[1]) - evidence(b[1])
851
+ || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
852
+ const toHit = ([, v]: [string, Acc]): HybridHit => ({
853
+ pattern: v.pattern,
854
+ backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
855
+ score: v.score,
856
+ });
857
+ // `slice(0, -1)` drops the LAST element instead of returning nothing, so a negative limit used to
858
+ // return almost the whole list (MEASURED: limit -1 over 5 candidates returned 4). `dz recall`
859
+ // clamps its own --limit, but this function is exported and the failure is silent, so it defends
860
+ // itself. `Infinity` means NO limit and must keep meaning that — the first version of this clamp
861
+ // rejected it along with NaN and returned nothing (found by independent review).
862
+ const limit = opts.limit === Number.POSITIVE_INFINITY
863
+ ? acc.size
864
+ : Number.isFinite(opts.limit) ? Math.max(0, Math.trunc(opts.limit)) : 0;
865
+ const cut = ordered.slice(0, limit);
866
+
867
+ // The lexical TOP-1 gets a RESERVED SEAT — but ONLY when the caller asked to emphasise the
868
+ // semantic leg. `--semantic` is meant to emphasise it, and instead REPLACES the lexical one: with
869
+ // RRF_K=60 a lexical hit at rank r is beaten by every semantic hit at rank s <= 61+2r, and the
870
+ // semantic list is capped at limit*2, so at any sane limit EVERY semantic hit outranks EVERY
871
+ // lexical one and an exact match on a rare identifier vanishes (MEASURED: one exact hit + three
872
+ // unrelated semantic hits at limit 3 → absent under weight 2, present under weight 1). Tuning the
873
+ // weight cannot fix it: any weight above ~1.02 has the same total effect.
874
+ //
875
+ // Gated on `weight > 1` because in HYBRID mode there is nothing to repair and the seat does real
876
+ // harm: at limit 1 it evicted a `both` hit scoring 0.0325 to seat a lexical-only hit scoring
877
+ // 0.0164 — half the score, and a hit BOTH legs had found (MEASURED, found by independent review;
878
+ // the first version of this ADR wrongly claimed the seat could not alter hybrid results).
879
+ // A `both` hit is never evicted: it is the strongest evidence the merge has.
880
+ const top = lexical[0];
881
+ if (weight > 1 && top !== undefined && limit > 0 && !cut.some(([id]) => id === top.id)) {
882
+ const seat = ordered.find(([id]) => id === top.id);
883
+ // Evict the WEAKEST hit that is not `both` — not simply the last one. Checking only the last
884
+ // seat meant a `both` hit sitting there blocked the reservation entirely, even when an evictable
885
+ // semantic-only hit stood right beside it: lexical [TOP, SHARED], semantic [S0…S59, SHARED],
886
+ // limit 2 → the cut is [S0, SHARED(both)] and TOP was dropped anyway (MEASURED, found by
887
+ // cross-family review). If every seat holds a `both` hit there is nothing to take, and the
888
+ // reservation is skipped: a hit both legs found is the strongest evidence the merge has.
889
+ let victim = -1;
890
+ for (let i = cut.length - 1; i >= 0; i -= 1) {
891
+ const entry = cut[i];
892
+ if (entry !== undefined && !(entry[1].lex !== undefined && entry[1].sem)) { victim = i; break; }
893
+ }
894
+ if (seat !== undefined && victim >= 0) cut[victim] = seat;
895
+ }
896
+ return cut.map(toHit);
769
897
  }
770
898
 
771
899
  function markRecallHits(projectRoot: string, backend: LearningSignalBackend, hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): void {
@@ -840,6 +968,8 @@ export async function recallHybrid(
840
968
  hits: enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) }))),
841
969
  lexicalBackend,
842
970
  vectorEngine: 'none',
971
+ semanticCandidates: 0,
972
+ semanticRanked: 0,
843
973
  ...extra,
844
974
  });
845
975
 
@@ -874,6 +1004,7 @@ export async function recallHybrid(
874
1004
  () => ({ hits: [] as VectorHit[], error: `vector search timed out after ${timeoutMs}ms` }),
875
1005
  );
876
1006
  if (sr.error !== undefined) {
1007
+ // The engine answered with an error, so nothing was returned and nothing ranked — both zero.
877
1008
  const out = { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
878
1009
  markRecallHits(projectRoot, learning, out.hits, idOf);
879
1010
  return out;
@@ -901,7 +1032,13 @@ export async function recallHybrid(
901
1032
  }));
902
1033
  const hits = enhance(mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 }));
903
1034
  markRecallHits(projectRoot, learning, hits, idOf);
904
- return { hits, lexicalBackend, vectorEngine: engine.kind };
1035
+ return {
1036
+ hits,
1037
+ lexicalBackend,
1038
+ vectorEngine: engine.kind,
1039
+ semanticCandidates: sr.hits.length,
1040
+ semanticRanked: semantic.length,
1041
+ };
905
1042
  }
906
1043
 
907
1044
  export async function teachGuard(
@@ -944,10 +1081,15 @@ export async function vectorTierStatus(
944
1081
  } catch {
945
1082
  records = [];
946
1083
  }
947
- const lexicalMirrorable = records.filter((r) => !isVectorNoise(r.text)).length;
948
- const pending = readVectorPending(projectRoot).length;
1084
+ const mirrorableRecords = records.filter((r) => !isVectorNoise(r.text));
1085
+ const lexicalMirrorable = mirrorableRecords.length;
1086
+ const pendingEntries = readVectorPending(projectRoot);
1087
+ const pending = pendingEntries.length;
1088
+ const mirrorWriterEnabled = vectorMirrorEnabled(projectRoot);
949
1089
  const resolved = pickEngine(projectRoot, opts);
950
1090
  if (resolved.engine === undefined) {
1091
+ // No engine to ask ⇒ the debt is UNKNOWN. Reporting 0 here is precisely the defect this feature
1092
+ // closes: a number nobody computed is not a number anyone may print (ADR-001, AM-5).
951
1093
  return {
952
1094
  mode,
953
1095
  available: false,
@@ -956,6 +1098,7 @@ export async function vectorTierStatus(
956
1098
  lexicalTotal: records.length,
957
1099
  lexicalMirrorable,
958
1100
  pending,
1101
+ mirrorWriterEnabled,
959
1102
  };
960
1103
  }
961
1104
  const engine = resolved.engine;
@@ -964,6 +1107,45 @@ export async function vectorTierStatus(
964
1107
  opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS,
965
1108
  () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
966
1109
  );
1110
+ // The debt is a SET DIFFERENCE over the content-addressed ids the mirror actually writes under,
1111
+ // not `mirrorable - mirrored - pending`: a vector can outlive its record (an orphan left by a
1112
+ // harmonize), which makes the subtraction negative and the flooring a fresh lie (ADR-001 alt-b).
1113
+ const accounted = new Set<string>([...listed.ids, ...pendingEntries.map((e) => e.dzId)]);
1114
+ const unmirrored = listed.error === undefined
1115
+ ? countUnmirrored(mirrorableRecords, accounted)
1116
+ : undefined;
1117
+
1118
+ // `listIds()` enumerates the OWNED SUPERSET — it also holds `dz-backlog` ideas — so its count may
1119
+ // not be printed beside a pattern-only lexical count. MEASURED on this repository: 547 ids against
1120
+ // 274 records, of which 273 were backlog idea ids and ZERO were true orphans; a reader took the
1121
+ // pair at face value and filed a task to prune a healthy index (ADR-001). Narrow it where the
1122
+ // engine can, and report `undefined` where it cannot — never a fabricated zero.
1123
+ const narrowed = listed.error === undefined && engine.listPatternIds !== undefined
1124
+ ? await withVectorTimeout(
1125
+ safeEngineCall(() => engine.listPatternIds!(), (m) => ({ ids: [] as string[], error: `vector listPatternIds failed: ${m}` })),
1126
+ opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS,
1127
+ () => ({ ids: [] as string[], error: 'vector listPatternIds timed out' }),
1128
+ )
1129
+ : undefined;
1130
+ // `known` is built from ALL records, not just the mirrorable ones: a vector written for a record
1131
+ // the noise gate later excluded still HAS a record, and calling it an orphan would send the user
1132
+ // pruning something that is merely un-mirrorable (found by cross-family review).
1133
+ const keysOf = (r: MemoryRecord): [string, string] => [r.id, patternRecordId(recordToPattern(r))];
1134
+ const known = new Set<string>();
1135
+ for (const r of records) for (const k of keysOf(r)) known.add(k);
1136
+
1137
+ const scoped = narrowed !== undefined && narrowed.error === undefined ? narrowed.ids : undefined;
1138
+ const scopedSet = scoped !== undefined ? new Set(scoped) : undefined;
1139
+ // Count RECORDS, not ids. A record has two possible keys and the mirror may hold BOTH, which
1140
+ // counting ids turns into two mirrored records where there is one (found by cross-family review).
1141
+ const mirrored = scopedSet !== undefined
1142
+ ? mirrorableRecords.filter((r) => keysOf(r).some((k) => scopedSet.has(k))).length
1143
+ : undefined;
1144
+ const orphaned = scoped !== undefined ? scoped.filter((id) => !known.has(id)).length : undefined;
1145
+ const mirroredOther = scoped !== undefined && listed.error === undefined
1146
+ ? listed.ids.length - scoped.length
1147
+ : undefined;
1148
+
967
1149
  return {
968
1150
  mode,
969
1151
  kind: engine.kind,
@@ -972,8 +1154,12 @@ export async function vectorTierStatus(
972
1154
  ...(!('error' in model) ? { embeddingModel: model.model } : {}),
973
1155
  lexicalTotal: records.length,
974
1156
  lexicalMirrorable,
975
- mirrored: listed.error === undefined ? listed.ids.length : undefined,
1157
+ mirrored,
976
1158
  pending,
1159
+ mirrorWriterEnabled,
1160
+ ...(unmirrored !== undefined ? { unmirrored } : {}),
1161
+ ...(mirroredOther !== undefined ? { mirroredOther } : {}),
1162
+ ...(orphaned !== undefined ? { orphaned } : {}),
977
1163
  };
978
1164
  }
979
1165
 
@@ -1453,6 +1639,10 @@ function agentdbVectorEngine(projectRoot: string): VectorEngine {
1453
1639
  async listIds() {
1454
1640
  return listAgentdbDzIds(projectRoot);
1455
1641
  },
1642
+ async listPatternIds() {
1643
+ // the NARROWED scope — what `lexicalMirrorable` counts, so the two numbers are comparable
1644
+ return listAgentdbDzIds(projectRoot, { taskTypes: DZ_PATTERN_TASK_TYPES });
1645
+ },
1456
1646
  async importVectors(rows) {
1457
1647
  return importVectorsToAgentdb(
1458
1648
  projectRoot,