@dzhechkov/harness-core 0.6.1 → 0.7.2

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 +60 -796
  2. package/README.md +40 -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 +38 -4
  28. package/dist/sign.d.ts.map +1 -1
  29. package/dist/sign.js +174 -8
  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 +5 -5
  36. package/sbom.json +89 -1929
  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 +162 -8
  44. package/src/vector-tier.ts +202 -12
package/src/sign.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { createHash, sign as cryptoSign, verify as cryptoVerify, createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
16
16
  import { readFileSync, existsSync, readdirSync, openSync, fstatSync, closeSync, constants as fsConstants } from 'node:fs';
17
- import { isAbsolute, join, relative, resolve, sep } from 'node:path';
17
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
18
18
 
19
19
  export const MANIFEST_NAME = '.dz-manifest.json';
20
20
  export const SBOM_NAME = 'sbom.json';
@@ -47,9 +47,117 @@ export interface VerifyResult {
47
47
  readonly failures: readonly VerifyFailure[];
48
48
  }
49
49
 
50
+
51
+ /**
52
+ * Files the PACKER REWRITES, so their bytes are not stable across two packs of the same unchanged
53
+ * tree — hashing them raw makes a signature that can never verify.
54
+ *
55
+ * MEASURED 2026-08-22: three consecutive `pnpm pack` runs of an unchanged `@dzhechkov/harness-core`
56
+ * (14 dependencies) produced THREE different `package.json` digests; the same three runs of
57
+ * `@dzhechkov/memory` (zero dependencies) produced one. The difference is the ORDER of dependency
58
+ * keys. So a package with dependencies could never satisfy its own signature: the signer packs once,
59
+ * the publisher packs again, and the consumer receives the second one.
60
+ *
61
+ * These files are therefore hashed in a CANONICAL form — parsed, keys sorted, stable separators.
62
+ * HONEST LIMIT: a pure reordering of keys becomes invisible to the signature. It changes nothing a
63
+ * consumer can observe, and the alternative — excusing `package.json` from signing altogether — would
64
+ * leave the dependency pins and the bin map unsigned, which is the opposite of a fix. The two
65
+ * differences that a consumer COULD observe — duplicate keys and precision-losing integers — are
66
+ * refused canonicalisation outright; see `canonicalisationIsFaithful`.
67
+ */
68
+ const CANONICALISED_PACK_FILES: ReadonlySet<string> = new Set(['package.json']);
69
+
70
+ /** Recursive, key-sorted JSON — the same bytes for any key order the packer chooses. */
71
+ function stableJson(value: unknown): string {
72
+ if (Array.isArray(value)) return '[' + value.map(stableJson).join(',') + ']';
73
+ if (value !== null && typeof value === 'object') {
74
+ const keys = Object.keys(value as Record<string, unknown>).sort();
75
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableJson((value as Record<string, unknown>)[k])).join(',') + '}';
76
+ }
77
+ return JSON.stringify(value) ?? 'null';
78
+ }
79
+
80
+ /**
81
+ * Is this JSON text one whose PARSED value is a faithful stand-in for its BYTES?
82
+ *
83
+ * Canonical hashing deliberately ignores key order and whitespace — differences no consumer can
84
+ * observe. Two differences are NOT of that kind, and a cross-family review (codex `gpt-5.6-sol`,
85
+ * 2026-08-22) named both:
86
+ *
87
+ * - DUPLICATE KEYS. `{"bin":"evil","bin":"signed"}` parses to the signed value in every JavaScript
88
+ * parser (last wins), so it would hash as clean — while a first-wins parser in another language
89
+ * reads `evil` from the same signed bytes.
90
+ * - LOSSY INTEGERS. `9007199254740993` parses to `...992`, so two different byte strings share one
91
+ * canonical form.
92
+ *
93
+ * Neither is reachable through `pnpm pack` — this is a guard against a hand-crafted artifact, not
94
+ * against the packer. On either, the caller hashes the RAW bytes instead, so an injected duplicate
95
+ * mismatches a canonical signature and the pack reads TAMPERED.
96
+ */
97
+ function canonicalisationIsFaithful(text: string): boolean {
98
+ const scopes: Array<Set<string> | null> = []; // Set = inside an object, null = inside an array
99
+ let i = 0;
100
+ while (i < text.length) {
101
+ const c = text.charAt(i);
102
+ if (c === '"') {
103
+ let j = i + 1;
104
+ while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === '\\' ? 2 : 1;
105
+ if (j >= text.length) return false; // unterminated — JSON.parse will reject it anyway
106
+ let key: string;
107
+ try {
108
+ key = JSON.parse(text.slice(i, j + 1)) as string;
109
+ } catch {
110
+ return false;
111
+ }
112
+ i = j + 1;
113
+ let k = i;
114
+ while (k < text.length && /\s/.test(text.charAt(k))) k++;
115
+ const scope = scopes[scopes.length - 1];
116
+ if (text.charAt(k) === ':' && scope instanceof Set) {
117
+ if (scope.has(key)) return false;
118
+ scope.add(key);
119
+ }
120
+ continue;
121
+ }
122
+ if (c === '{') { scopes.push(new Set()); i++; continue; }
123
+ if (c === '[') { scopes.push(null); i++; continue; }
124
+ if (c === '}' || c === ']') { scopes.pop(); i++; continue; }
125
+ if (c === '-' || (c >= '0' && c <= '9')) {
126
+ const start = i;
127
+ while (i < text.length && /[-+0-9eE.]/.test(text.charAt(i))) i++;
128
+ const lit = text.slice(start, i);
129
+ // Only plain integers can lose precision silently; `1.0` and `1e0` spell the same value in
130
+ // every parser, so they are legitimate canonical equivalences, not a differential.
131
+ if (/^-?\d+$/.test(lit) && String(Number(lit)) !== lit) return false;
132
+ continue;
133
+ }
134
+ i++;
135
+ }
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Hash the bytes of one pack file. A packer-rewritten file is hashed canonically; everything else
141
+ * byte-for-byte. Unparseable JSON falls back to the RAW bytes rather than passing — a corrupt
142
+ * `package.json` must be a mismatch, never a free pass.
143
+ */
144
+ export function hashPackBytes(relPath: string, bytes: Buffer): string {
145
+ const rel = relPath.split(sep).join('/');
146
+ if (CANONICALISED_PACK_FILES.has(rel)) {
147
+ try {
148
+ const text = bytes.toString('utf-8');
149
+ if (!canonicalisationIsFaithful(text)) throw new Error('not canonicalisable');
150
+ return createHash('sha256').update(Buffer.from(stableJson(JSON.parse(text)), 'utf-8')).digest('hex');
151
+ } catch {
152
+ /* fall through to the raw bytes */
153
+ }
154
+ }
155
+ return createHash('sha256').update(bytes).digest('hex');
156
+ }
157
+
50
158
  /** sha256 of a file's bytes, hex. Follows symlinks — used only when building a manifest we control. */
51
- export function hashFile(absPath: string): string {
52
- return createHash('sha256').update(readFileSync(absPath)).digest('hex');
159
+ export function hashFile(absPath: string, relPath?: string): string {
160
+ return hashPackBytes(relPath ?? basename(absPath), readFileSync(absPath));
53
161
  }
54
162
 
55
163
  /**
@@ -59,12 +167,14 @@ export function hashFile(absPath: string): string {
59
167
  *
60
168
  * Returns `null` when the path is not a regular file, or is a symlink.
61
169
  */
62
- export function hashRegularFileNoFollow(absPath: string): string | null {
170
+ export function hashRegularFileNoFollow(absPath: string, relPath?: string): string | null {
63
171
  let fd: number | undefined;
64
172
  try {
65
173
  fd = openSync(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
66
174
  if (!fstatSync(fd).isFile()) return null;
67
- return createHash('sha256').update(readFileSync(fd)).digest('hex');
175
+ // Canonicalise the bytes ALREADY READ from this descriptor — never re-open, or the TOCTOU
176
+ // window this function exists to close would be reopened by the fix.
177
+ return hashPackBytes(relPath ?? basename(absPath), readFileSync(fd));
68
178
  } catch {
69
179
  return null; // ELOOP on a symlink, ENOENT, EACCES — all fail closed
70
180
  } finally {
@@ -192,7 +302,7 @@ export function canonicalizeSigned(manifest: Manifest): Buffer {
192
302
  export function buildManifest(root: string, pack: string, files: readonly string[]): Manifest {
193
303
  const entries = files.map((rel) => ({
194
304
  path: rel.split(sep).join('/'),
195
- sha256: hashFile(join(root, rel)),
305
+ sha256: hashFile(join(root, rel), rel),
196
306
  }));
197
307
  return { version: MANIFEST_VERSION, pack, files: entries };
198
308
  }
@@ -246,6 +356,19 @@ export function verifyManifest(
246
356
  root: string,
247
357
  signed: SignedManifest | null | undefined,
248
358
  pubKeyPem: string,
359
+ /**
360
+ * The paths the pack actually SHIPS, when the caller can establish them (from `npm pack`). The
361
+ * added-file sweep is then scoped to those, because a working-tree file `files[]` excludes was
362
+ * never "added to the pack" — it simply is not part of it.
363
+ *
364
+ * Omit it and the sweep covers the whole tree, exactly as before. That is correct for the case that
365
+ * matters most: a consumer verifying an EXTRACTED tarball, where the tree IS the shipped set.
366
+ *
367
+ * This parameter exists because the signer and the sweep must never disagree about what a pack
368
+ * contains — the 10-false-TAMPERED lesson, recorded at `packFiles` and re-learned on 2026-08-21 the
369
+ * moment the signer started scoping and the sweep did not.
370
+ */
371
+ shippedPaths?: readonly string[],
249
372
  ): VerifyResult {
250
373
  const fail = (path: string, reason: string): VerifyResult => ({ ok: false, failures: [{ path, reason }] });
251
374
 
@@ -289,7 +412,7 @@ export function verifyManifest(
289
412
  continue;
290
413
  }
291
414
  // One open, O_NOFOLLOW, fstat the descriptor, hash from it: no symlink follow, no TOCTOU window.
292
- const digest = hashRegularFileNoFollow(abs);
415
+ const digest = hashRegularFileNoFollow(abs, entry.path);
293
416
  if (digest === null) {
294
417
  failures.push({ path: entry.path, reason: 'is a symlink or not a regular file — refusing to hash it' });
295
418
  continue;
@@ -300,10 +423,14 @@ export function verifyManifest(
300
423
  }
301
424
 
302
425
  // Bidirectional, always: hashing only what the manifest lists lets an attacker ADD a file.
426
+ const shipped = shippedPaths === undefined ? null : new Set(shippedPaths);
303
427
  const present = listPackFiles(root);
304
428
  const listed = new Set(manifest.files.map((f) => f.path));
305
429
  for (const rel of present) {
306
430
  const p = rel.split(sep).join('/');
431
+ // A file the pack does not ship is not an ADDED file; scoping here is what keeps the sweep and the
432
+ // signer describing the same object.
433
+ if (shipped !== null && !shipped.has(p)) continue;
307
434
  if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
308
435
  }
309
436
 
@@ -384,6 +511,12 @@ export interface PublishGateInput {
384
511
  readonly manifestPresent: boolean;
385
512
  readonly verifyOk: boolean;
386
513
  readonly requireSigning: boolean;
514
+ /**
515
+ * The artifact could not be produced (the packer failed), so nothing was compared against the
516
+ * signature. Distinct from `verifyOk: false`, which means a comparison RAN and disagreed — the
517
+ * gate blocks either way, but only an honest reason tells the operator which one to fix.
518
+ */
519
+ readonly artifactUnavailable?: boolean;
387
520
  }
388
521
 
389
522
  export interface PublishGateDecision {
@@ -404,6 +537,12 @@ export function decidePublishGate(input: PublishGateInput): PublishGateDecision
404
537
  if (!input.manifestPresent) {
405
538
  return { action: 'block', reason: 'trust root is present but the pack carries no signature manifest' };
406
539
  }
540
+ if (input.artifactUnavailable === true) {
541
+ return {
542
+ action: 'block',
543
+ reason: 'the artifact could not be packed, so its signature was never checked against what ships',
544
+ };
545
+ }
407
546
  if (!input.verifyOk) {
408
547
  return { action: 'block', reason: 'the pack does not match its signed manifest' };
409
548
  }
@@ -415,7 +554,7 @@ export function decidePublishGate(input: PublishGateInput): PublishGateDecision
415
554
  // A verifier nobody runs is a signature nobody checks. These two pure functions carry the whole
416
555
  // security content of `dz doctor` / `dz upgrade`; the CLI bodies only print and exit.
417
556
 
418
- export type PackVerdict = 'verified' | 'unsigned' | 'tampered' | 'no-trust-root';
557
+ export type PackVerdict = 'verified' | 'unsigned' | 'tampered' | 'no-trust-root' | 'source-tree';
419
558
 
420
559
  export interface TrustRootCandidates {
421
560
  /** `--pubkey <path>`, if the caller passed one and it exists. */
@@ -458,11 +597,26 @@ export interface PolicyDecision {
458
597
  * | unsigned | report | fail |
459
598
  * | tampered | FAIL | FAIL |
460
599
  * | no-trust-root | report | fail |
600
+ * | source-tree | report | report |
601
+ *
602
+ * `source-tree` was added on 2026-08-21, when the manifest began describing the PUBLISHED TARBALL
603
+ * rather than the working tree. Those are two different objects — `pnpm publish` re-serialises
604
+ * package.json and rewrites `workspace:*` — so a source checkout CANNOT be hash-verified against a
605
+ * tarball-scoped manifest, and calling that TAMPERED is a false alarm. It is not `verified` either:
606
+ * nothing was established. It stays `report` even under `--require-signing`, because the honest
607
+ * remedy is to verify the artifact (`dz verify-pack` packs and checks the tarball), not to fail a
608
+ * developer's checkout for being a checkout.
461
609
  *
462
610
  * `tampered` is fatal in both columns; `no-trust-root` is never success. That is the load-bearing
463
611
  * property. Today every pack is `unsigned` or `no-trust-root` — the leg is wired, not armed.
464
612
  */
465
613
  export function decideVerifyPolicy(verdict: PackVerdict, requireSigning: boolean): PolicyDecision {
614
+ if (verdict === 'source-tree') {
615
+ return {
616
+ action: 'report',
617
+ reason: 'source checkout — its manifest describes the published tarball, which this tree is not; run `dz verify-pack` to check the artifact',
618
+ };
619
+ }
466
620
  if (verdict === 'tampered') {
467
621
  return { action: 'fail', reason: 'pack does not match its signed manifest' };
468
622
  }
@@ -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,