@dzhechkov/harness-core 0.7.0 → 0.7.3

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 (62) hide show
  1. package/.dz-manifest.json +116 -56
  2. package/README.md +3 -0
  3. package/dist/event-chain.d.ts +50 -0
  4. package/dist/event-chain.d.ts.map +1 -1
  5. package/dist/event-chain.js +31 -0
  6. package/dist/event-chain.js.map +1 -1
  7. package/dist/index.d.ts +8 -5
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -3
  10. package/dist/index.js.map +1 -1
  11. package/dist/name-check.d.ts +98 -0
  12. package/dist/name-check.d.ts.map +1 -0
  13. package/dist/name-check.js +333 -0
  14. package/dist/name-check.js.map +1 -0
  15. package/dist/operations.d.ts.map +1 -1
  16. package/dist/operations.js +25 -4
  17. package/dist/operations.js.map +1 -1
  18. package/dist/provenance.d.ts +100 -92
  19. package/dist/provenance.d.ts.map +1 -1
  20. package/dist/provenance.js +122 -122
  21. package/dist/provenance.js.map +1 -1
  22. package/dist/recall-domain-boost.d.ts +10 -3
  23. package/dist/recall-domain-boost.d.ts.map +1 -1
  24. package/dist/recall-domain-boost.js +7 -0
  25. package/dist/recall-domain-boost.js.map +1 -1
  26. package/dist/recall-hook-policy.d.ts +15 -0
  27. package/dist/recall-hook-policy.d.ts.map +1 -1
  28. package/dist/recall-hook-policy.js +59 -0
  29. package/dist/recall-hook-policy.js.map +1 -1
  30. package/dist/recap.d.ts +146 -0
  31. package/dist/recap.d.ts.map +1 -0
  32. package/dist/recap.js +346 -0
  33. package/dist/recap.js.map +1 -0
  34. package/dist/retro.d.ts +131 -0
  35. package/dist/retro.d.ts.map +1 -0
  36. package/dist/retro.js +207 -0
  37. package/dist/retro.js.map +1 -0
  38. package/dist/score.d.ts +21 -0
  39. package/dist/score.d.ts.map +1 -1
  40. package/dist/score.js +44 -3
  41. package/dist/score.js.map +1 -1
  42. package/dist/sign.d.ts +14 -2
  43. package/dist/sign.d.ts.map +1 -1
  44. package/dist/sign.js +140 -7
  45. package/dist/sign.js.map +1 -1
  46. package/dist/vector-tier.d.ts +54 -0
  47. package/dist/vector-tier.d.ts.map +1 -1
  48. package/dist/vector-tier.js +69 -8
  49. package/dist/vector-tier.js.map +1 -1
  50. package/package.json +6 -6
  51. package/sbom.json +205 -55
  52. package/src/event-chain.ts +64 -0
  53. package/src/index.ts +12 -0
  54. package/src/name-check.ts +331 -0
  55. package/src/operations.ts +26 -5
  56. package/src/provenance.ts +217 -0
  57. package/src/recall-domain-boost.ts +10 -3
  58. package/src/recall-hook-policy.ts +60 -0
  59. package/src/recap.ts +462 -0
  60. package/src/score.ts +53 -3
  61. package/src/sign.ts +129 -7
  62. package/src/vector-tier.ts +109 -10
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
  }
@@ -302,7 +412,7 @@ export function verifyManifest(
302
412
  continue;
303
413
  }
304
414
  // One open, O_NOFOLLOW, fstat the descriptor, hash from it: no symlink follow, no TOCTOU window.
305
- const digest = hashRegularFileNoFollow(abs);
415
+ const digest = hashRegularFileNoFollow(abs, entry.path);
306
416
  if (digest === null) {
307
417
  failures.push({ path: entry.path, reason: 'is a symlink or not a regular file — refusing to hash it' });
308
418
  continue;
@@ -401,6 +511,12 @@ export interface PublishGateInput {
401
511
  readonly manifestPresent: boolean;
402
512
  readonly verifyOk: boolean;
403
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;
404
520
  }
405
521
 
406
522
  export interface PublishGateDecision {
@@ -421,6 +537,12 @@ export function decidePublishGate(input: PublishGateInput): PublishGateDecision
421
537
  if (!input.manifestPresent) {
422
538
  return { action: 'block', reason: 'trust root is present but the pack carries no signature manifest' };
423
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
+ }
424
546
  if (!input.verifyOk) {
425
547
  return { action: 'block', reason: 'the pack does not match its signed manifest' };
426
548
  }
@@ -133,8 +133,21 @@ export interface ImportVectorRow {
133
133
  }
134
134
 
135
135
  /** The engine PORT — both adapters implement exactly this surface (04 §4.1). */
136
+ /**
137
+ * Is this engine's `similarity` a real cosine, or only good enough to RANK by?
138
+ *
139
+ * `agentdb` computes a magnitude-normalised cosine over the stored vectors, so its number is
140
+ * comparable and lives in the space the recall floors were calibrated in. The `rvf` adapter returns
141
+ * `-distance` — unbounded, and the metric is not established — so its number may only order rows.
142
+ * Printing it beside a 0.38 cosine floor would hand the reader a figure that looks calibrated and is
143
+ * not; that is the one lie this feature must not tell (ADR, features/recall-true-closeness).
144
+ */
145
+ export type SimilarityKind = 'cosine' | 'rank-only';
146
+
136
147
  export interface VectorEngine {
137
148
  readonly kind: VectorEngineKind;
149
+ /** Absent means `rank-only`: an engine must SAY its number is a cosine to have it shown as one. */
150
+ readonly similarityKind?: SimilarityKind;
138
151
  upsert(entries: readonly VectorEntry[]): Promise<{ indexed: number; error?: string | undefined }>;
139
152
  search(query: string, limit: number): Promise<{ hits: VectorHit[]; error?: string | undefined }>;
140
153
  listIds(): Promise<{ ids: string[]; error?: string | undefined }>;
@@ -173,6 +186,14 @@ export interface HybridHit {
173
186
  readonly score: number;
174
187
  /** lesson-quarantine: set only for a quarantined hit — display marks ⚠q, ranking was damped. */
175
188
  readonly quarantined?: boolean;
189
+ /**
190
+ * Raw closeness from the semantic leg, when the engine reports a genuine cosine. ABSENT for a
191
+ * lexical-only hit and for an engine whose score is not a cosine — the honest display there is a
192
+ * dash, never a substitute number. On a `both` hit this is the SEMANTIC leg's cosine, which
193
+ * explains less of the ordering than it may appear to: the list is ranked by RRF plus learning
194
+ * signals, not by this.
195
+ */
196
+ readonly similarity?: number;
176
197
  }
177
198
 
178
199
  /** Outcome of {@link recallHybrid}. With no engine this is content-identical to `recallPatterns`. */
@@ -243,6 +264,8 @@ export interface VectorTierStatus {
243
264
  * project print a fully-healthy status line over a dead writer (ADR-001).
244
265
  */
245
266
  readonly mirrorWriterEnabled: boolean;
267
+ /** WHY it is on or off — so the surface printing it cannot invent a cause (see mirrorWriterReason). */
268
+ readonly mirrorWriterState: MirrorWriterState;
246
269
  /**
247
270
  * Vectors of OTHER dz-owned task types (today: `dz-backlog`). Reported separately so a store of N
248
271
  * vectors can be fully accounted for, and never folded into `mirrored`, which must stay comparable
@@ -521,15 +544,63 @@ export function readHarmonizeThreshold(projectRoot: string): number {
521
544
  * `false`, so its `dz teach` output stays byte-identical to the pre-feature baseline (AC-1).
522
545
  */
523
546
  export function vectorMirrorEnabled(projectRoot: string): boolean {
547
+ return mirrorWriterReason(projectRoot).enabled;
548
+ }
549
+
550
+ /** Why the mirror writer is on or off — a CLOSED set, so a caller cannot invent a cause. */
551
+ export type MirrorWriterState =
552
+ | 'on'
553
+ /** No `.dz/config.json` at all — an unconfigured project, which is the normal quiet case. */
554
+ | 'no-config'
555
+ /** The config exists but could not be read or parsed. */
556
+ | 'config-unreadable'
557
+ /** The config is readable and simply does not enable a mirror. */
558
+ | 'not-enabled'
559
+ /** The config explicitly turns the vector tier off. */
560
+ | 'engine-off';
561
+
562
+ /**
563
+ * The mirror writer's state AND its real cause.
564
+ *
565
+ * Every failure used to collapse into `false`, and the one message printed above it named ONE
566
+ * specific cause: "no memory.backend=agentdb". MEASURED 2026-08-24 with a config that sets
567
+ * `memory.vector.engine: "off"` — the status line correctly said OFF and then blamed a setting the
568
+ * config did not mention. A diagnosis that names the wrong cause sends the reader to fix something
569
+ * that is not broken, which is worse than saying nothing.
570
+ */
571
+ export function mirrorWriterReason(projectRoot: string): { enabled: boolean; state: MirrorWriterState } {
572
+ const path = join(projectRoot, '.dz', 'config.json');
573
+ if (!existsSync(path)) return { enabled: false, state: 'no-config' };
574
+ let cfg: { memory?: { backend?: string; vector?: { engine?: string } } };
524
575
  try {
525
- const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
526
- memory?: { backend?: string; vector?: { engine?: string } };
527
- };
528
- if (cfg.memory?.backend === 'agentdb') return true;
529
- const engine = cfg.memory?.vector?.engine;
530
- return engine === 'agentdb' || engine === 'rvf';
576
+ cfg = JSON.parse(readFileSync(path, 'utf-8')) as typeof cfg;
531
577
  } catch {
532
- return false;
578
+ return { enabled: false, state: 'config-unreadable' };
579
+ }
580
+ const engine = cfg.memory?.vector?.engine;
581
+ // An explicit `off` WINS over `memory.backend`, because that is what the engine resolution itself
582
+ // does. With `{"memory":{"backend":"agentdb","vector":{"engine":"off"}}}` the same status output
583
+ // printed `Engine: none — vector tier disabled` and `Mirror writer: ON` one line apart
584
+ // (cross-family review, codex `gpt-5.6-sol`, 2026-08-24). A report that contradicts itself inside
585
+ // one screen is worse than either half alone, and the half that was wrong is this one: nothing can
586
+ // queue to a tier that is off.
587
+ if (engine === 'off') return { enabled: false, state: 'engine-off' };
588
+ if (cfg.memory?.backend === 'agentdb') return { enabled: true, state: 'on' };
589
+ if (engine === 'agentdb' || engine === 'rvf') return { enabled: true, state: 'on' };
590
+ return { enabled: false, state: 'not-enabled' };
591
+ }
592
+
593
+ /** The sentence a reader can act on, for each state. */
594
+ export function mirrorWriterExplanation(state: MirrorWriterState): string {
595
+ switch (state) {
596
+ case 'on': return 'teach is queueing to the mirror';
597
+ // "has never configured" is a claim about HISTORY from an observation about the PRESENT: the
598
+ // file may be tracked in git and merely deleted from the working tree (cross-family review,
599
+ // codex gpt-5.6-sol, 2026-08-24). Absence proves only absence.
600
+ case 'no-config': return 'no .dz/config.json here — memory is not configured in this working tree, and nothing is queueing';
601
+ case 'config-unreadable': return '.dz/config.json exists but could not be read or parsed — fix the file, not the settings';
602
+ case 'engine-off': return '.dz/config.json sets memory.vector.engine = "off" — the tier is deliberately disabled';
603
+ case 'not-enabled': return '.dz/config.json enables no mirror (needs memory.backend=agentdb, or memory.vector.engine=agentdb|rvf) — teach is NOT queueing';
533
604
  }
534
605
  }
535
606
 
@@ -806,6 +877,13 @@ export interface RankedPattern {
806
877
  readonly id: string;
807
878
  readonly pattern: PatternRecord;
808
879
  readonly backend: RecallHit['backend'];
880
+ /**
881
+ * The semantic leg's raw closeness, when the engine reports a real cosine. Rides ALONGSIDE the RRF
882
+ * score and never enters the ranking maths — four things depend on RRF magnitude (the reinforce
883
+ * cap, quarantine damping, the learning uplift, and an ADR-level note in backlog.ts), so this is a
884
+ * sibling field, never a repurposing.
885
+ */
886
+ readonly similarity?: number;
809
887
  }
810
888
 
811
889
  const RRF_K = 60;
@@ -827,7 +905,7 @@ export function mergeHybridHits(
827
905
  opts: { readonly limit: number; readonly semanticWeight?: number | undefined },
828
906
  ): HybridHit[] {
829
907
  const weight = opts.semanticWeight ?? 1;
830
- interface Acc { pattern: PatternRecord; lex?: RecallHit['backend']; sem: boolean; score: number }
908
+ interface Acc { pattern: PatternRecord; lex?: RecallHit['backend']; sem: boolean; score: number; similarity?: number }
831
909
  const acc = new Map<string, Acc>();
832
910
  lexical.forEach((h, rank) => {
833
911
  const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
@@ -839,6 +917,10 @@ export function mergeHybridHits(
839
917
  const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
840
918
  cur.sem = true;
841
919
  cur.score += weight / (RRF_K + rank + 1);
920
+ // The cosine rides along untouched by the ranking maths. On a hit both legs found, this is the
921
+ // SEMANTIC leg's number — stated in the field's own doc, because it explains less of the order
922
+ // than it looks like it does.
923
+ if (h.similarity !== undefined) cur.similarity = h.similarity;
842
924
  acc.set(h.id, cur);
843
925
  });
844
926
  // Ties break by EVIDENCE, not by the id alphabet: a hit both legs found outranks one only a single
@@ -853,6 +935,7 @@ export function mergeHybridHits(
853
935
  pattern: v.pattern,
854
936
  backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
855
937
  score: v.score,
938
+ ...(v.similarity === undefined ? {} : { similarity: v.similarity }),
856
939
  });
857
940
  // `slice(0, -1)` drops the LAST element instead of returning nothing, so a negative limit used to
858
941
  // return almost the whole list (MEASURED: limit -1 over 5 candidates returned 4). `dz recall`
@@ -1016,6 +1099,8 @@ export async function recallHybrid(
1016
1099
  const p = recordToPattern(r);
1017
1100
  idToPattern.set(r.id, p);
1018
1101
  }
1102
+ // Only a declared cosine is allowed to travel as one (ADR: per-engine honesty gate).
1103
+ const cosineEngine = engine.similarityKind === 'cosine';
1019
1104
  const semantic: RankedPattern[] = [];
1020
1105
  const seen = new Set<string>();
1021
1106
  for (const h of sr.hits) {
@@ -1023,7 +1108,13 @@ export async function recallHybrid(
1023
1108
  const p = idToPattern.get(h.dzId);
1024
1109
  if (p === undefined) continue; // vector-only orphan — the store pruned/expired it; NEVER resurrect
1025
1110
  seen.add(h.dzId);
1026
- semantic.push({ id: h.dzId, pattern: p, backend: 'vector' });
1111
+ // The cosine was computed by the engine and then thrown away here — `h.similarity` was read
1112
+ // nowhere in this function, and `relevance` in `--json` has been the RRF rank surrogate ever
1113
+ // since (MEASURED: a nonsense query and a meaningful one both score 1/61 at the top). It now
1114
+ // travels with the hit. Only a genuine cosine travels: an engine whose score is a negated
1115
+ // distance reports `similarityKind: 'rank-only'` and contributes nothing here.
1116
+ semantic.push({ id: h.dzId, pattern: p, backend: 'vector',
1117
+ ...(cosineEngine && Number.isFinite(h.similarity) ? { similarity: h.similarity } : {}) });
1027
1118
  }
1028
1119
  const lex: RankedPattern[] = lexical.map((h) => ({
1029
1120
  id: identityToId.get(patternIdentityOf(h.pattern)) ?? patternRecordId(h.pattern),
@@ -1085,7 +1176,8 @@ export async function vectorTierStatus(
1085
1176
  const lexicalMirrorable = mirrorableRecords.length;
1086
1177
  const pendingEntries = readVectorPending(projectRoot);
1087
1178
  const pending = pendingEntries.length;
1088
- const mirrorWriterEnabled = vectorMirrorEnabled(projectRoot);
1179
+ const mirror = mirrorWriterReason(projectRoot);
1180
+ const mirrorWriterEnabled = mirror.enabled;
1089
1181
  const resolved = pickEngine(projectRoot, opts);
1090
1182
  if (resolved.engine === undefined) {
1091
1183
  // No engine to ask ⇒ the debt is UNKNOWN. Reporting 0 here is precisely the defect this feature
@@ -1099,6 +1191,7 @@ export async function vectorTierStatus(
1099
1191
  lexicalMirrorable,
1100
1192
  pending,
1101
1193
  mirrorWriterEnabled,
1194
+ mirrorWriterState: mirror.state,
1102
1195
  };
1103
1196
  }
1104
1197
  const engine = resolved.engine;
@@ -1157,6 +1250,7 @@ export async function vectorTierStatus(
1157
1250
  mirrored,
1158
1251
  pending,
1159
1252
  mirrorWriterEnabled,
1253
+ mirrorWriterState: mirror.state,
1160
1254
  ...(unmirrored !== undefined ? { unmirrored } : {}),
1161
1255
  ...(mirroredOther !== undefined ? { mirroredOther } : {}),
1162
1256
  ...(orphaned !== undefined ? { orphaned } : {}),
@@ -1613,6 +1707,9 @@ export async function importRvfCheckpoint(projectRoot: string, source: string, o
1613
1707
  function agentdbVectorEngine(projectRoot: string): VectorEngine {
1614
1708
  return {
1615
1709
  kind: 'agentdb',
1710
+ // A magnitude-normalising cosine over the stored vectors — comparable, and in the same space the
1711
+ // recall floors were calibrated in.
1712
+ similarityKind: 'cosine',
1616
1713
  async upsert(entries) {
1617
1714
  const r = await indexPatternsToAgentdb(
1618
1715
  projectRoot,
@@ -1775,6 +1872,8 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
1775
1872
  const noEmbedder = 'rvf engine present but no embedder — install agentdb (dz setup --memory agentdb)';
1776
1873
  return {
1777
1874
  kind: 'rvf',
1875
+ // `-distance`, unbounded, metric not established: good for ORDER, never for a threshold.
1876
+ similarityKind: 'rank-only',
1778
1877
  async upsert(entries) {
1779
1878
  const idmap = readRvfIdmap(projectRoot);
1780
1879
  const guard = guardRvfEmbedSpace(projectRoot, idmap);