@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.
- package/.dz-manifest.json +60 -796
- package/README.md +40 -1
- package/dist/agentdb-index.d.ts +11 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +8 -1
- package/dist/agentdb-index.js.map +1 -1
- package/dist/guard.d.ts +29 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +54 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/publish-signing.d.ts +113 -0
- package/dist/publish-signing.d.ts.map +1 -0
- package/dist/publish-signing.js +124 -0
- package/dist/publish-signing.js.map +1 -0
- package/dist/publish.d.ts +21 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +106 -0
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +19 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +91 -1
- package/dist/registry.js.map +1 -1
- package/dist/sign.d.ts +38 -4
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +174 -8
- package/dist/sign.js.map +1 -1
- package/dist/vector-tier.d.ts +81 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +137 -10
- package/dist/vector-tier.js.map +1 -1
- package/package.json +5 -5
- package/sbom.json +89 -1929
- package/src/agentdb-index.ts +8 -1
- package/src/guard.ts +74 -0
- package/src/index.ts +3 -1
- package/src/publish-signing.ts +217 -0
- package/src/publish.ts +102 -1
- package/src/registry.ts +84 -1
- package/src/sign.ts +162 -8
- 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
|
|
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
|
-
|
|
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
|
}
|
package/src/vector-tier.ts
CHANGED
|
@@ -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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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 {
|
|
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
|
|
948
|
-
const
|
|
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
|
|
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,
|