@dzhechkov/harness-core 0.8.34 → 0.8.36
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 +61 -61
- package/README.md +255 -12
- package/dist/agentdb-index.d.ts +22 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +156 -6
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +180 -2
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +781 -38
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +17 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +88 -9
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -22
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +60 -60
- package/src/agentdb-index.ts +158 -7
- package/src/apply-leg.ts +824 -38
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/index.ts +15 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +91 -10
- package/src/publish.ts +247 -30
- package/src/release-line.ts +32 -0
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +147 -24
package/src/skills.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
|
-
import { join, relative } from 'node:path';
|
|
7
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
8
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
9
9
|
|
|
10
10
|
import { parse as parseYaml } from 'yaml';
|
|
11
11
|
|
|
@@ -55,15 +55,259 @@ function readAssetContent(path: string): { encoding: 'utf-8' | 'base64'; content
|
|
|
55
55
|
return { encoding: 'utf-8', content: decoded };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
/**
|
|
59
|
+
* One filesystem entry skipped during skill asset discovery, named so nothing
|
|
60
|
+
* vanishes silently (feature `skills-walk-symlinks-and-junk`, FR-1/FR-2). `path`
|
|
61
|
+
* is the entry's own path (not its symlink target); `reason` is a short,
|
|
62
|
+
* stable, human-readable tag: `'junk file (<pattern>)'` (e.g. `'junk file
|
|
63
|
+
* (*.pyc)'` — fix-round 1 HIGH-1(b): the pattern that matched, not just the
|
|
64
|
+
* verdict), `'junk directory (<name>)'`, `'broken symlink'`, `'symlink escapes
|
|
65
|
+
* the skill directory'` (fix-round 1 AM-8), `'symlink cycle — directory
|
|
66
|
+
* already visited'`, or `'unreadable directory (<errno message>)'` (fix-round
|
|
67
|
+
* 1 MEDIUM-3).
|
|
68
|
+
*/
|
|
69
|
+
export interface SkippedEntry {
|
|
70
|
+
readonly path: string;
|
|
71
|
+
readonly reason: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The result of {@link walkFiles}: the real assets found, plus everything skipped. */
|
|
75
|
+
export interface SkillWalkResult {
|
|
76
|
+
readonly files: readonly string[];
|
|
77
|
+
readonly skipped: readonly SkippedEntry[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Directory names that never carry legitimate skill assets — build/cache artifacts a
|
|
82
|
+
* skill author does not intend to ship. **This list IS the published contract for "what
|
|
83
|
+
* counts as junk"** (fix-round 1 HIGH-1(a), REFUTED-by-contract — see
|
|
84
|
+
* `packages/@dzhechkov/harness-core/README.md`, "What counts as junk"): a skill cannot
|
|
85
|
+
* ship a directory bearing one of these exact names as an asset, on purpose or by
|
|
86
|
+
* accident — that is the deliberate, documented trade the design makes, not an
|
|
87
|
+
* oversight to be widened into content-sniffing heuristics. NOT a whitelist of allowed
|
|
88
|
+
* directories: any OTHER name (including a dotdir the author added on purpose) is
|
|
89
|
+
* walked as usual — filtering a user's own files is not this list's job (FR-2,
|
|
90
|
+
* requirements AC-1).
|
|
91
|
+
*/
|
|
92
|
+
export const SKILL_JUNK_DIRS: ReadonlySet<string> = new Set([
|
|
93
|
+
'__pycache__', // Python bytecode cache
|
|
94
|
+
'node_modules', // an accidentally-vendored dependency tree
|
|
95
|
+
'.git', // VCS metadata
|
|
96
|
+
'__MACOSX', // macOS zip-archive resource-fork sidecar directory (fix-round 1 MEDIUM-2)
|
|
97
|
+
'.pytest_cache', // pytest's cache directory (fix-round 1 MEDIUM-2)
|
|
98
|
+
'.mypy_cache', // mypy's cache directory (fix-round 1 MEDIUM-2)
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/** Exact junk filenames skipped during skill asset discovery (FR-2). */
|
|
102
|
+
export const SKILL_JUNK_FILES: ReadonlySet<string> = new Set([
|
|
103
|
+
'.DS_Store', // macOS Finder folder metadata
|
|
104
|
+
'Thumbs.db', // Windows Explorer thumbnail cache
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/** Junk filename SUFFIXES skipped during skill asset discovery (FR-2). */
|
|
108
|
+
const SKILL_JUNK_FILE_SUFFIXES: readonly string[] = [
|
|
109
|
+
'.pyc', // Python bytecode
|
|
110
|
+
'.pyo', // Python optimized bytecode
|
|
111
|
+
'.swp', // Vim swap file
|
|
112
|
+
'.swo', // Vim swap file, second form left after a crash recovery (fix-round 1 MEDIUM-2)
|
|
113
|
+
// A trailing `~` (editor backup) is deliberately NOT on this list (Codex r2 HIGH, lead
|
|
114
|
+
// decision): it is the one pattern a legitimate asset name can plausibly end with
|
|
115
|
+
// (`notes~`), and FR-2's list is conservative by contract — a false positive here would
|
|
116
|
+
// silently drop a real asset, which AC-1 forbids. Backup files ending in `~` ship as assets.
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/** Junk filename PREFIXES skipped during skill asset discovery (fix-round 1 MEDIUM-2). */
|
|
120
|
+
const SKILL_JUNK_FILE_PREFIXES: readonly string[] = [
|
|
121
|
+
'.#', // Emacs lock file (e.g. `.#notes.txt`), left behind by an unclean editor exit
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/** True when `name` matches a documented junk-file pattern (FR-2). */
|
|
125
|
+
export function isSkillJunkFile(name: string): boolean {
|
|
126
|
+
if (SKILL_JUNK_FILES.has(name)) return true;
|
|
127
|
+
if (SKILL_JUNK_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
|
|
128
|
+
return SKILL_JUNK_FILE_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The ONE decision point `walkFiles` calls to ask "is this entry junk?" — a directory
|
|
133
|
+
* check and a file check both funnel through here so a single mutation can prove (or
|
|
134
|
+
* disprove) that junk filtering, as a whole, is wired in (registry entry
|
|
135
|
+
* `walk-filters-junk`). Splitting this into two never-both-mutated call sites would let
|
|
136
|
+
* a mutation of just one half pass unnoticed while the other half still filtered.
|
|
137
|
+
*/
|
|
138
|
+
function isJunkEntry(name: string, kind: 'file' | 'directory'): boolean {
|
|
139
|
+
return kind === 'directory' ? SKILL_JUNK_DIRS.has(name) : isSkillJunkFile(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Which junk PATTERN `name` matched — used to build a `reason` that NAMES the pattern,
|
|
144
|
+
* not just the verdict (fix-round 1 HIGH-1(b): `'junk file (*.pyc)'`, never a bare
|
|
145
|
+
* `'junk file'`). Kept separate from {@link isJunkEntry} (the one go/no-go point the
|
|
146
|
+
* `walk-filters-junk` mutation targets) so a mutation of the decision does not also
|
|
147
|
+
* have to fake this label to stay silent.
|
|
148
|
+
*/
|
|
149
|
+
function junkPatternLabel(name: string, kind: 'file' | 'directory'): string {
|
|
150
|
+
if (kind === 'directory') return name;
|
|
151
|
+
if (SKILL_JUNK_FILES.has(name)) return name;
|
|
152
|
+
for (const suffix of SKILL_JUNK_FILE_SUFFIXES) {
|
|
153
|
+
if (name.endsWith(suffix)) return `*${suffix}`;
|
|
154
|
+
}
|
|
155
|
+
for (const prefix of SKILL_JUNK_FILE_PREFIXES) {
|
|
156
|
+
if (name.startsWith(prefix)) return `${prefix}*`;
|
|
157
|
+
}
|
|
158
|
+
return name; // unreachable when isJunkEntry(name, kind) is true; kept total, not partial.
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* True when `targetRealPath` lies within `rootRealDir` — refuses a symlink whose
|
|
163
|
+
* resolved target escapes the skill directory (lead item AM-8: `assets/secret ->
|
|
164
|
+
* /etc/hostname`, or `-> ../../..`, must never be followed and bundled as an asset).
|
|
165
|
+
* Uses `relative()` plus a leading-`..`/absolute check rather than a lexical
|
|
166
|
+
* `startsWith`, the SAME idiom as `containedUnderRoot` in
|
|
167
|
+
* `packages/@dzhechkov/harness-cli/src/cli.ts` (read-only reference for technique) —
|
|
168
|
+
* a lexical string-prefix check is fooled by a sibling directory that happens to share
|
|
169
|
+
* the root as a text prefix (root `/a/b` vs. target `/a/bc`).
|
|
170
|
+
*/
|
|
171
|
+
function isContained(rootRealDir: string, targetRealPath: string): boolean {
|
|
172
|
+
if (targetRealPath === rootRealDir) return true;
|
|
173
|
+
const rel = relative(rootRealDir, targetRealPath);
|
|
174
|
+
// Codex r2 MEDIUM (lead fix): only a `..` COMPONENT escapes — a file legitimately named
|
|
175
|
+
// `..asset` yields rel === '..asset', which is inside the root.
|
|
176
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Recursively list every real, non-junk file under `dir`, resolving symlinks to
|
|
181
|
+
* their targets and guarding against symlink cycles.
|
|
182
|
+
*
|
|
183
|
+
* A `Dirent` from `readdirSync` answers `false` to BOTH `isDirectory()` and
|
|
184
|
+
* `isFile()` for a symlink entry — trusting those two checks alone silently drops
|
|
185
|
+
* every symlinked asset (MEASURED: 2 of 4 fixture assets vanished, exit 0). This
|
|
186
|
+
* resolves each symlink with `statSync` (which follows the link) before deciding
|
|
187
|
+
* whether it names a file or a directory.
|
|
188
|
+
*
|
|
189
|
+
* Cycle guard: each directory's `realpath` is recorded in `seenRealDirs` while
|
|
190
|
+
* it is being walked and REMOVED again when its walk returns — the set is the
|
|
191
|
+
* chain of ANCESTORS on the current recursion path, not every directory ever
|
|
192
|
+
* visited. A directory (reached directly or through a symlink) whose real path is
|
|
193
|
+
* already on that chain ends the walk there instead of recursing — this is what
|
|
194
|
+
* stops `a -> ..` from hanging (AC-2; registry entry `walk-guards-cycles`). Two
|
|
195
|
+
* non-cyclic aliases of the same directory (`alias1 -> shared`, `alias2 -> shared`)
|
|
196
|
+
* are BOTH walked under their own logical paths (Codex r2 HIGH, lead fix): an
|
|
197
|
+
* alias is not a cycle, and the earlier visited-set semantics silently dropped the
|
|
198
|
+
* second one as if it were.
|
|
199
|
+
*
|
|
200
|
+
* Containment guard (lead item AM-8): every symlink's resolved target is checked
|
|
201
|
+
* against `rootRealDir` — the real path of the directory the OUTERMOST call was
|
|
202
|
+
* given (the skill directory itself, for every caller in this file) — before it is
|
|
203
|
+
* followed. A symlink whose target resolves outside that root is skipped, named,
|
|
204
|
+
* never bundled as an asset; `rootRealDir` is threaded through every recursive call
|
|
205
|
+
* so a nested symlinked directory is still checked against the ORIGINAL skill root,
|
|
206
|
+
* not against whichever subdirectory happens to be walking it.
|
|
207
|
+
*
|
|
208
|
+
* `readdirSync` failure (fix-round 1 MEDIUM-3) — e.g. an unreadable directory whose
|
|
209
|
+
* own `realpath` still resolved — produces a named `skipped` entry, same as an
|
|
210
|
+
* unresolvable `realpathSync`; it never throws out of this function or out of
|
|
211
|
+
* {@link loadSkillFromDir}.
|
|
212
|
+
*/
|
|
213
|
+
export function walkFiles(
|
|
214
|
+
dir: string,
|
|
215
|
+
seenRealDirs: Set<string> = new Set<string>(),
|
|
216
|
+
rootRealDir?: string,
|
|
217
|
+
): SkillWalkResult {
|
|
218
|
+
const files: string[] = [];
|
|
219
|
+
const skipped: SkippedEntry[] = [];
|
|
220
|
+
|
|
221
|
+
let realDir: string;
|
|
222
|
+
try {
|
|
223
|
+
realDir = realpathSync(dir);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
skipped.push({ path: dir, reason: `unreadable directory (${error instanceof Error ? error.message : String(error)})` });
|
|
226
|
+
return { files, skipped };
|
|
227
|
+
}
|
|
228
|
+
if (seenRealDirs.has(realDir)) {
|
|
229
|
+
skipped.push({ path: dir, reason: 'symlink cycle — directory already visited' });
|
|
230
|
+
return { files, skipped };
|
|
231
|
+
}
|
|
232
|
+
seenRealDirs.add(realDir);
|
|
233
|
+
const root = rootRealDir ?? realDir;
|
|
234
|
+
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
skipped.push({ path: dir, reason: `unreadable directory (${error instanceof Error ? error.message : String(error)})` });
|
|
240
|
+
seenRealDirs.delete(realDir);
|
|
241
|
+
return { files, skipped };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const entry of entries) {
|
|
62
245
|
const full = join(dir, entry.name);
|
|
63
|
-
|
|
64
|
-
|
|
246
|
+
|
|
247
|
+
if (entry.isSymbolicLink()) {
|
|
248
|
+
let target;
|
|
249
|
+
try {
|
|
250
|
+
target = statSync(full); // follows the link
|
|
251
|
+
} catch {
|
|
252
|
+
skipped.push({ path: full, reason: 'broken symlink' });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
let resolvedReal: string;
|
|
256
|
+
try {
|
|
257
|
+
resolvedReal = realpathSync(full);
|
|
258
|
+
} catch {
|
|
259
|
+
// statSync just followed this same link successfully, so this is very unlikely
|
|
260
|
+
// (a race with something deleting the target); treat it the same as broken.
|
|
261
|
+
skipped.push({ path: full, reason: 'broken symlink' });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!isContained(root, resolvedReal)) {
|
|
265
|
+
skipped.push({ path: full, reason: 'symlink escapes the skill directory' });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (target.isDirectory()) {
|
|
269
|
+
if (isJunkEntry(entry.name, 'directory')) {
|
|
270
|
+
skipped.push({ path: full, reason: `junk directory (${junkPatternLabel(entry.name, 'directory')})` });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const nested = walkFiles(full, seenRealDirs, root);
|
|
274
|
+
files.push(...nested.files);
|
|
275
|
+
skipped.push(...nested.skipped);
|
|
276
|
+
} else if (target.isFile()) {
|
|
277
|
+
if (isJunkEntry(entry.name, 'file')) {
|
|
278
|
+
skipped.push({ path: full, reason: `junk file (${junkPatternLabel(entry.name, 'file')})` });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
files.push(full);
|
|
282
|
+
} else {
|
|
283
|
+
skipped.push({ path: full, reason: 'symlink target is neither a file nor a directory' });
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (entry.isDirectory()) {
|
|
289
|
+
if (isJunkEntry(entry.name, 'directory')) {
|
|
290
|
+
skipped.push({ path: full, reason: `junk directory (${junkPatternLabel(entry.name, 'directory')})` });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const nested = walkFiles(full, seenRealDirs, root);
|
|
294
|
+
files.push(...nested.files);
|
|
295
|
+
skipped.push(...nested.skipped);
|
|
296
|
+
} else if (entry.isFile()) {
|
|
297
|
+
if (isJunkEntry(entry.name, 'file')) {
|
|
298
|
+
skipped.push({ path: full, reason: `junk file (${junkPatternLabel(entry.name, 'file')})` });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
files.push(full);
|
|
302
|
+
} else {
|
|
303
|
+
skipped.push({ path: full, reason: 'not a regular file, directory, or symlink' });
|
|
304
|
+
}
|
|
65
305
|
}
|
|
66
|
-
|
|
306
|
+
|
|
307
|
+
// Ancestor-chain semantics (see the cycle-guard doc above): this directory's walk is
|
|
308
|
+
// over, so a sibling alias of it must be allowed to walk it again under its own path.
|
|
309
|
+
seenRealDirs.delete(realDir);
|
|
310
|
+
return { files, skipped };
|
|
67
311
|
}
|
|
68
312
|
|
|
69
313
|
/** Return the ids of every `<skillsDir>/<id>/SKILL.md`, sorted. */
|
|
@@ -88,6 +332,9 @@ export function discoverSkillIds(skillsDir: string): string[] {
|
|
|
88
332
|
*/
|
|
89
333
|
export function listSkills(skillsDir: string): SkillSummary[] {
|
|
90
334
|
return discoverSkillIds(skillsDir).map((id) => {
|
|
335
|
+
// Codex r3 (lead fix): the SKILL.md containment check guards EVERY public reader, not
|
|
336
|
+
// only loadSkillFromDir/getSkillInfo — an escaping SKILL.md symlink is refused here too.
|
|
337
|
+
assertSkillMdContained(join(skillsDir, id), join(skillsDir, id, 'SKILL.md'), id);
|
|
91
338
|
const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
|
|
92
339
|
const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
|
|
93
340
|
return { id, description: frontmatter.description };
|
|
@@ -146,6 +393,9 @@ export function describeSkillLoadFailure(
|
|
|
146
393
|
const path = join(skillsDir, id, 'SKILL.md');
|
|
147
394
|
let firstLine = '';
|
|
148
395
|
try {
|
|
396
|
+
// Codex r3 (lead fix): never read an ESCAPING SKILL.md even for a diagnostic snippet —
|
|
397
|
+
// the first line of a file outside the skill directory is not ours to print.
|
|
398
|
+
assertSkillMdContained(join(skillsDir, id), path, id);
|
|
149
399
|
const raw = readFileSync(path, 'utf-8');
|
|
150
400
|
const line = (raw.split('\n', 1)[0] ?? '').replace(/\r$/, '').trim();
|
|
151
401
|
firstLine =
|
|
@@ -176,6 +426,8 @@ export function listSkillsDetailed(skillsDir: string): SkillListing {
|
|
|
176
426
|
const failures: SkillLoadFailure[] = [];
|
|
177
427
|
for (const id of discoverSkillIds(skillsDir)) {
|
|
178
428
|
try {
|
|
429
|
+
// Codex r3 (lead fix): refused BEFORE the read; the throw lands in `failures` below.
|
|
430
|
+
assertSkillMdContained(join(skillsDir, id), join(skillsDir, id, 'SKILL.md'), id);
|
|
179
431
|
const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
|
|
180
432
|
const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
|
|
181
433
|
skills.push({ id, description: frontmatter.description });
|
|
@@ -258,15 +510,36 @@ export function formatSkillApplyFailures(failures: readonly SkillApplyFailure[])
|
|
|
258
510
|
}
|
|
259
511
|
|
|
260
512
|
/** Get detailed info about a single skill without loading all assets. */
|
|
513
|
+
/**
|
|
514
|
+
* Codex r2 CRITICAL (lead fix, AM-8 completed): the walk's containment guard runs AFTER
|
|
515
|
+
* `SKILL.md` has already been read, so a `SKILL.md` that is itself a symlink escaping
|
|
516
|
+
* the skill directory (`SKILL.md -> /etc/motd`, `-> ../../outside.md`) was followed and
|
|
517
|
+
* installed regardless. `SKILL.md` is mandatory, so an escaping one cannot be "skipped" —
|
|
518
|
+
* the whole skill is REFUSED with a named reason, never loaded from outside its directory.
|
|
519
|
+
* An in-tree `SKILL.md` symlink (a real file elsewhere inside the same skill directory)
|
|
520
|
+
* still loads. Returns nothing; throws on escape.
|
|
521
|
+
*/
|
|
522
|
+
function assertSkillMdContained(skillDir: string, skillMdPath: string, id: string): void {
|
|
523
|
+
if (!lstatSync(skillMdPath).isSymbolicLink()) return;
|
|
524
|
+
const rootReal = realpathSync(skillDir);
|
|
525
|
+
const targetReal = realpathSync(skillMdPath);
|
|
526
|
+
if (!isContained(rootReal, targetReal)) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`skill ${JSON.stringify(id)}: SKILL.md is a symlink escaping the skill directory (-> ${targetReal}) — refused`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
261
533
|
export function getSkillInfo(skillsDir: string, id: string): SkillInfo | undefined {
|
|
262
534
|
const skillDir = join(skillsDir, id);
|
|
263
535
|
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
264
536
|
if (!existsSync(skillMdPath)) return undefined;
|
|
537
|
+
assertSkillMdContained(skillDir, skillMdPath, id);
|
|
265
538
|
const document = parseSkillDocument(readFileSync(skillMdPath, 'utf-8'));
|
|
266
539
|
const fm = parseYaml(document.frontmatterYaml) as Record<string, unknown>;
|
|
267
540
|
const parsed = ClaudeSkillFrontmatterSchema.parse(fm);
|
|
268
541
|
const assetPaths = walkFiles(skillDir)
|
|
269
|
-
.filter((p) => p !== skillMdPath)
|
|
542
|
+
.files.filter((p) => p !== skillMdPath)
|
|
270
543
|
.map((p) => relative(skillDir, p).split('\\').join('/'))
|
|
271
544
|
.sort();
|
|
272
545
|
return {
|
|
@@ -281,21 +554,35 @@ export function getSkillInfo(skillsDir: string, id: string): SkillInfo | undefin
|
|
|
281
554
|
};
|
|
282
555
|
}
|
|
283
556
|
|
|
557
|
+
/**
|
|
558
|
+
* A {@link CanonicalSkill} plus, optionally, the {@link SkippedEntry} list
|
|
559
|
+
* {@link loadSkillFromDir} collected while walking the skill's directory. The
|
|
560
|
+
* field is additive and optional — a `CanonicalSkill`-typed caller (every
|
|
561
|
+
* adapter, every existing consumer) sees exactly the shape it always saw;
|
|
562
|
+
* only a caller that reads `.skipped` learns about junk/broken-symlink skips.
|
|
563
|
+
*/
|
|
564
|
+
export type LoadedSkill = CanonicalSkill & { readonly skipped?: readonly SkippedEntry[] };
|
|
565
|
+
|
|
284
566
|
/**
|
|
285
567
|
* Load one `<skillsDir>/<id>/` directory into a {@link CanonicalSkill}: its
|
|
286
|
-
* `SKILL.md` document plus every other file as a bundled asset.
|
|
568
|
+
* `SKILL.md` document plus every other file as a bundled asset. Junk entries
|
|
569
|
+
* and broken/cyclic symlinks encountered along the way are named in
|
|
570
|
+
* `.skipped` (feature `skills-walk-symlinks-and-junk`, FR-1/FR-2) — omitted
|
|
571
|
+
* entirely when nothing was skipped, never a silent drop.
|
|
287
572
|
*
|
|
288
573
|
* @throws if the skill directory has no `SKILL.md`.
|
|
289
574
|
*/
|
|
290
|
-
export function loadSkillFromDir(skillsDir: string, id: string):
|
|
575
|
+
export function loadSkillFromDir(skillsDir: string, id: string): LoadedSkill {
|
|
291
576
|
const skillDir = join(skillsDir, id);
|
|
292
577
|
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
293
578
|
if (!existsSync(skillMdPath)) {
|
|
294
579
|
throw new Error(`skill not found: ${JSON.stringify(id)} (looked in ${skillsDir})`);
|
|
295
580
|
}
|
|
581
|
+
assertSkillMdContained(skillDir, skillMdPath, id);
|
|
296
582
|
const document = parseSkillDocument(readFileSync(skillMdPath, 'utf-8'));
|
|
297
583
|
const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
|
|
298
|
-
const
|
|
584
|
+
const walk = walkFiles(skillDir);
|
|
585
|
+
const assets: SkillAsset[] = walk.files
|
|
299
586
|
.filter((path) => path !== skillMdPath)
|
|
300
587
|
.map((path) => {
|
|
301
588
|
const { encoding, content } = readAssetContent(path);
|
|
@@ -306,5 +593,7 @@ export function loadSkillFromDir(skillsDir: string, id: string): CanonicalSkill
|
|
|
306
593
|
};
|
|
307
594
|
})
|
|
308
595
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
309
|
-
return
|
|
596
|
+
return walk.skipped.length > 0
|
|
597
|
+
? { id, frontmatter, document, assets, skipped: walk.skipped }
|
|
598
|
+
: { id, frontmatter, document, assets };
|
|
310
599
|
}
|
package/src/vector-tier.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
reindexAgentdbRows,
|
|
70
70
|
readAgentdbRowsByTaskType,
|
|
71
71
|
DZ_OWNED_TASK_TYPES,
|
|
72
|
+
readStoreGeneration,
|
|
72
73
|
} from './agentdb-index.js';
|
|
73
74
|
// smart-backlog (ADR-001/005 lifecycle): `dz vector reindex` must re-embed dz-backlog rows too, or
|
|
74
75
|
// they rot in a stale embedding space after a model bump. One-directional import — backlog.ts imports
|
|
@@ -857,12 +858,17 @@ interface EngineCacheEntry {
|
|
|
857
858
|
readonly stat: AgentdbDbStat;
|
|
858
859
|
}
|
|
859
860
|
|
|
860
|
-
/** The
|
|
861
|
+
/** The four independent invalidation signals: the three AM-6 asks for (mtime/size/inode) PLUS the
|
|
862
|
+
* store's own write-generation counter (`store-generation-counter`, FR-2) — a same-size same-tick
|
|
863
|
+
* temp+rename replace can still leave mtime/size/inode all coincidentally unchanged on a coarse
|
|
864
|
+
* filesystem, but `indexPatternsToAgentdb` bumps the generation on every real write, so it is the
|
|
865
|
+
* one signal that can never coincidentally match a stale cache entry. Plus the recency stamp
|
|
861
866
|
* {@link touchEngineCacheEntry} needs for the bounded-size eviction below. */
|
|
862
867
|
interface AgentdbDbStat {
|
|
863
868
|
readonly mtimeMs: number;
|
|
864
869
|
readonly size: number;
|
|
865
870
|
readonly ino: number;
|
|
871
|
+
readonly generation: number;
|
|
866
872
|
lastUsedAt: number;
|
|
867
873
|
}
|
|
868
874
|
|
|
@@ -897,21 +903,25 @@ function engineCacheKey(projectRoot: string): string {
|
|
|
897
903
|
|
|
898
904
|
/** stat facts of `<root>/.dz/agentdb.db`, or `-1`/`-1`/`-1` when absent — a distinct, stable cache
|
|
899
905
|
* key for "no store yet" so a project that later gains a store is never confused with one that
|
|
900
|
-
* never had (statSync's own floor is mtime 0).
|
|
901
|
-
|
|
906
|
+
* never had (statSync's own floor is mtime 0). `generation` is read regardless of whether the stat
|
|
907
|
+
* itself succeeded (`readStoreGeneration` already degrades a missing/corrupt counter file to `0`,
|
|
908
|
+
* FR-2's compatibility floor for a pre-existing store). Never throws. */
|
|
909
|
+
function agentdbDbStat(projectRoot: string): { mtimeMs: number; size: number; ino: number; generation: number } {
|
|
910
|
+
const generation = readStoreGeneration(projectRoot);
|
|
902
911
|
try {
|
|
903
912
|
const st = statSync(join(projectRoot, '.dz', 'agentdb.db'));
|
|
904
|
-
return { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino };
|
|
913
|
+
return { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino, generation };
|
|
905
914
|
} catch {
|
|
906
|
-
return { mtimeMs: -1, size: -1, ino: -1 };
|
|
915
|
+
return { mtimeMs: -1, size: -1, ino: -1, generation };
|
|
907
916
|
}
|
|
908
917
|
}
|
|
909
918
|
|
|
910
|
-
/** True when NONE of the
|
|
911
|
-
* may still be trusted (AM-6). Any one of them differing (a
|
|
912
|
-
* gets a fresh inode from a temp+rename write
|
|
913
|
-
|
|
914
|
-
|
|
919
|
+
/** True when NONE of the four independent signals changed — the only case where a cached engine
|
|
920
|
+
* may still be trusted (AM-6, and `store-generation-counter` FR-2). Any one of them differing (a
|
|
921
|
+
* same-tick replace still bumps size or gets a fresh inode from a temp+rename write, and every real
|
|
922
|
+
* write bumps the generation regardless) forces a re-resolve. */
|
|
923
|
+
function agentdbDbStatUnchanged(a: AgentdbDbStat, b: { mtimeMs: number; size: number; ino: number; generation: number }): boolean {
|
|
924
|
+
return a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino && a.generation === b.generation;
|
|
915
925
|
}
|
|
916
926
|
|
|
917
927
|
/** Evict the least-recently-used entry once the cache is at capacity — called only on a genuine
|
|
@@ -1148,13 +1158,87 @@ export interface RankedPattern {
|
|
|
1148
1158
|
|
|
1149
1159
|
const RRF_K = 60;
|
|
1150
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* One entry in the total order every post-merge ranking step shares (feature
|
|
1163
|
+
* `recall-parity-tie-break`, FR-1). `evidence` is the SAME three-way rank `mergeHybridHits` has
|
|
1164
|
+
* always used (`both` < lexical-only < semantic-only — lower is stronger), computed once by
|
|
1165
|
+
* {@link evidenceRank} from a hit's `backend`.
|
|
1166
|
+
*/
|
|
1167
|
+
export interface HybridOrderKey {
|
|
1168
|
+
readonly score: number;
|
|
1169
|
+
readonly evidence: number;
|
|
1170
|
+
readonly dzId: string;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** `both` outranks lexical-only outranks semantic-only (mergeHybridHits' own rule, ADR-001 AM-4). */
|
|
1174
|
+
export function evidenceRank(backend: RecallHit['backend']): number {
|
|
1175
|
+
return backend === 'both' ? 0 : backend === 'vector' ? 2 : 1;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* The ONE deterministic total order recall uses at every step where a tie can occur: fused score
|
|
1180
|
+
* DESC, then EVIDENCE (both > lexical-only > semantic-only), then `dzId` ASC. `mergeHybridHits`
|
|
1181
|
+
* always applied exactly this rule inline; it is exported here (FR-1) so `dampQuarantined`,
|
|
1182
|
+
* `orderHitsForReRank` (the pre-sort `enhance()` runs before its reinforcement/bandit re-rank) and
|
|
1183
|
+
* any other post-merge sort can share the SAME tiebreak instead of an ad hoc score-only comparator
|
|
1184
|
+
* that is deterministic only because its input already arrived pre-ordered — a property that
|
|
1185
|
+
* silently breaks the moment an upstream step feeds it hits in a different order
|
|
1186
|
+
* (recall-parity-tie-break T0: MEASURED, `apply-leg-recall-parity.test.ts` AM-4, a racy
|
|
1187
|
+
* reinforcement-signal read, not a comparator defect, actually explained the observed tail swap —
|
|
1188
|
+
* this comparator is hardening kept from that round; the actual causal fix, per fix-round 1, is the
|
|
1189
|
+
* byte-level store snapshot AM-4 now takes, not this comparator and not an awaited flush).
|
|
1190
|
+
*
|
|
1191
|
+
* FINITE-NUMBER INVARIANT (fix-round 1, LOW finding): plain subtraction (`b.score - a.score`) is
|
|
1192
|
+
* NOT total over `number` — `NaN - x` is `NaN`, and the `||` chain treats a `NaN` term as falsy,
|
|
1193
|
+
* silently SKIPPING it and falling through to the next key as if score had never been compared.
|
|
1194
|
+
* Both terms below use explicit `>`/`<` comparisons instead (correct as-is for ±Infinity — IEEE 754
|
|
1195
|
+
* orders infinities correctly) plus an explicit NaN case: a `NaN` score or evidence is the WEAKEST
|
|
1196
|
+
* possible value on its own axis, so it sorts deterministically LAST, never a coincidental tie.
|
|
1197
|
+
*/
|
|
1198
|
+
function compareScoreDesc(a: number, b: number): number {
|
|
1199
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? 1 : -1;
|
|
1200
|
+
return a > b ? -1 : a < b ? 1 : 0;
|
|
1201
|
+
}
|
|
1202
|
+
function compareEvidenceAsc(a: number, b: number): number {
|
|
1203
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? 1 : -1;
|
|
1204
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1205
|
+
}
|
|
1206
|
+
export function compareHybridHits(a: HybridOrderKey, b: HybridOrderKey): number {
|
|
1207
|
+
return compareScoreDesc(a.score, b.score)
|
|
1208
|
+
|| compareEvidenceAsc(a.evidence, b.evidence)
|
|
1209
|
+
|| (a.dzId < b.dzId ? -1 : a.dzId > b.dzId ? 1 : 0);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Sorts `hits` into the shared total order {@link compareHybridHits} defines — used by `enhance()`
|
|
1214
|
+
* BEFORE its reinforcement/bandit re-rank runs (`applyLearningSignalsWithTerms` et al.,
|
|
1215
|
+
* `learning-backend.ts`, out of this fix's edit scope). That re-rank sorts by an ADJUSTED score
|
|
1216
|
+
* with a STABLE tie-break on each hit's ORIGINAL array position — so pre-ordering the input here
|
|
1217
|
+
* makes any tie in the adjusted score resolve in the SAME evidence/dzId order `compareHybridHits`
|
|
1218
|
+
* would give directly, without touching the re-rank's own internals.
|
|
1219
|
+
*
|
|
1220
|
+
* This closes the ordering gap `enhance()` had (recall-parity-tie-break fix-round 1, HIGH finding):
|
|
1221
|
+
* `dampQuarantined` only ran {@link compareHybridHits} when `memory.learning.quarantine` was ON;
|
|
1222
|
+
* with it OFF (the default), `enhance()`'s final order was whatever the re-rank's own
|
|
1223
|
+
* original-index tie-break happened to preserve — invisible from the printed `score` column,
|
|
1224
|
+
* because the re-rank reorders the hit array but never rewrites `.score`. Real (non-tied) score
|
|
1225
|
+
* differences from reinforcement/bandit re-ranking are UNCHANGED by this — it only decides ties.
|
|
1226
|
+
*/
|
|
1227
|
+
export function orderHitsForReRank(hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): HybridHit[] {
|
|
1228
|
+
return [...hits].sort((a, b) => compareHybridHits(
|
|
1229
|
+
{ score: a.score, evidence: evidenceRank(a.backend), dzId: idOf(a.pattern) },
|
|
1230
|
+
{ score: b.score, evidence: evidenceRank(b.backend), dzId: idOf(b.pattern) },
|
|
1231
|
+
));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1151
1234
|
/**
|
|
1152
1235
|
* Reciprocal Rank Fusion merge: `score(p) = Σ 1/(60 + rank)` over the lists containing `p`
|
|
1153
1236
|
* (semantic ranks weighted by `semanticWeight`). Dedup by id; `backend: 'both'` when a pattern
|
|
1154
1237
|
*
|
|
1155
|
-
* Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id
|
|
1156
|
-
* When `semanticWeight > 1` the lexical top-1 is guaranteed a place in
|
|
1157
|
-
* last seat unless that seat holds a `both` hit. See
|
|
1238
|
+
* Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id
|
|
1239
|
+
* — {@link compareHybridHits}. When `semanticWeight > 1` the lexical top-1 is guaranteed a place in
|
|
1240
|
+
* the result, taken from the last seat unless that seat holds a `both` hit. See
|
|
1241
|
+
* `features/semantic-keeps-exact-hits`.
|
|
1158
1242
|
*
|
|
1159
1243
|
* appears in both lists. DETERMINISTIC (AC-6): ties break on id, so fixed inputs always yield
|
|
1160
1244
|
* the same ordering. Pure — no I/O.
|
|
@@ -1186,12 +1270,14 @@ export function mergeHybridHits(
|
|
|
1186
1270
|
});
|
|
1187
1271
|
// Ties break by EVIDENCE, not by the id alphabet: a hit both legs found outranks one only a single
|
|
1188
1272
|
// leg found. Before this, an exact-term match lost a tie to an arbitrary semantic hit purely
|
|
1189
|
-
// because its id sorted later (ADR-001 AM-4).
|
|
1190
|
-
|
|
1273
|
+
// because its id sorted later (ADR-001 AM-4). Now routed through the shared {@link
|
|
1274
|
+
// compareHybridHits} (FR-1) — same three-part rule, no behaviour change.
|
|
1275
|
+
const evidenceOfAcc = (v: Acc): number => evidenceRank(v.lex !== undefined && v.sem ? 'both' : v.lex ?? 'vector');
|
|
1191
1276
|
const ordered = [...acc.entries()]
|
|
1192
|
-
.sort((a, b) =>
|
|
1193
|
-
|
|
1194
|
-
|
|
1277
|
+
.sort((a, b) => compareHybridHits(
|
|
1278
|
+
{ score: a[1].score, evidence: evidenceOfAcc(a[1]), dzId: a[0] },
|
|
1279
|
+
{ score: b[1].score, evidence: evidenceOfAcc(b[1]), dzId: b[0] },
|
|
1280
|
+
));
|
|
1195
1281
|
const toHit = ([, v]: [string, Acc]): HybridHit => ({
|
|
1196
1282
|
pattern: v.pattern,
|
|
1197
1283
|
backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
|
|
@@ -1246,6 +1332,27 @@ export function mergeHybridHits(
|
|
|
1246
1332
|
*
|
|
1247
1333
|
* `bandit` is passed ONLY when `memory.learning.banditRerank` is armed; when it is absent this
|
|
1248
1334
|
* function is byte-identical to its pre-feature self — no state file, no lock, no allocation.
|
|
1335
|
+
*
|
|
1336
|
+
* `backend.train()` fires FIRE-AND-FORGET (`void backend.train().catch(() => undefined)`) — this is
|
|
1337
|
+
* a REVERT (recall-parity-tie-break, fix-round 1, MEDIUM finding). An intermediate version of this
|
|
1338
|
+
* fix AWAITED the flush, on the theory that a caller's own reinforcement write landing before it got
|
|
1339
|
+
* an answer would remove the race `apply-leg-recall-parity.test.ts` AM-4 was catching (two tail hits
|
|
1340
|
+
* swapping order between the daemon's `op:recall` reply and a `dz recall --json` invoked a moment
|
|
1341
|
+
* later — MEASURED byte-identical `score` fields, only `uses` differed, so the merge/comparator was
|
|
1342
|
+
* never the cause). MEASURED (lead, 2026-09-15 15:57, temp project, 8 lexical hits, 5 warm runs):
|
|
1343
|
+
* `recallHybrid` took 2 ms with `onRecallHits:false` (no flush at all) vs 57–131 ms with the flush
|
|
1344
|
+
* AWAITED — landing INSIDE the hook's 500 ms `HOOK_RECALL_BUDGET_MS` but a real, avoidable tax on
|
|
1345
|
+
* every recall, for a property the await did not even fully deliver: the awaited write still let a
|
|
1346
|
+
* CLI invoked immediately after the daemon see the DAEMON'S OWN just-computed exposure for that same
|
|
1347
|
+
* query, answering a subtly different question than the daemon had. AM-4 now proves parity by taking
|
|
1348
|
+
* a byte-level SNAPSHOT of the store BEFORE each query's daemon call and pointing `dz recall --json`
|
|
1349
|
+
* at the frozen snapshot (`--project <snapshot>`) — both sides then answer the identical question
|
|
1350
|
+
* from the identical state under PRODUCTION defaults (`onRecallHits` ON), and no write, awaited or
|
|
1351
|
+
* not, can reach the CLI's read. That snapshot is what actually closes the race; this function stays
|
|
1352
|
+
* fire-and-forget, exactly as it always was, because the snapshot makes its timing irrelevant to the
|
|
1353
|
+
* test. `test/lesson-bandit-byte-identity.test.ts` documents the same underlying race in its own
|
|
1354
|
+
* fixture comment and works around it with `onRecallHits: false` there — a narrower, still-valid
|
|
1355
|
+
* isolation for a different test's needs.
|
|
1249
1356
|
*/
|
|
1250
1357
|
function markRecallHits(
|
|
1251
1358
|
projectRoot: string,
|
|
@@ -1346,7 +1453,15 @@ export async function recallHybrid(
|
|
|
1346
1453
|
const q = rec !== undefined && readQuarantineState(rec).quarantined;
|
|
1347
1454
|
return q ? { ...h, score: h.score * memCfg.quarantineDamp, quarantined: true as const } : h;
|
|
1348
1455
|
})
|
|
1349
|
-
|
|
1456
|
+
// FR-1 (recall-parity-tie-break): score-only used to rely on the INCOMING array already
|
|
1457
|
+
// being pre-ordered (native Array.sort is stable, so a genuine tie only stayed put by
|
|
1458
|
+
// accident of arrival order). Routed through the same {@link compareHybridHits} the merge
|
|
1459
|
+
// uses, so damping two equally-scored hits can never reorder them differently from how the
|
|
1460
|
+
// merge itself would have.
|
|
1461
|
+
.sort((a, b) => compareHybridHits(
|
|
1462
|
+
{ score: a.score, evidence: evidenceRank(a.backend), dzId: idOf(a.pattern) },
|
|
1463
|
+
{ score: b.score, evidence: evidenceRank(b.backend), dzId: idOf(b.pattern) },
|
|
1464
|
+
));
|
|
1350
1465
|
};
|
|
1351
1466
|
// lesson-bandit-rerank (ADR-001): the payoff axis. Resolved ONCE per recall; `enabled:false` ⇒
|
|
1352
1467
|
// the Lesson Payoff context is NEVER CONSTRUCTED — the branch is taken BEFORE any work, so the
|
|
@@ -1356,7 +1471,15 @@ export async function recallHybrid(
|
|
|
1356
1471
|
let banditReport: BanditRecallReport | undefined;
|
|
1357
1472
|
let banditExplored: readonly string[] = [];
|
|
1358
1473
|
const enhance = (hits: readonly HybridHit[]): HybridHit[] => {
|
|
1359
|
-
|
|
1474
|
+
// FR-1 (recall-parity-tie-break, fix-round 1, HIGH finding): pre-sort into the shared total
|
|
1475
|
+
// order BEFORE the reinforcement/bandit re-rank runs — see {@link orderHitsForReRank}.
|
|
1476
|
+
// Why the pre-sort is sufficient and not "reliance on a stable sort" (Codex round 2): the
|
|
1477
|
+
// re-rank's own sort in learning-backend.ts is `b.adjusted - a.adjusted || a.i - b.i` — an
|
|
1478
|
+
// EXPLICIT tie-break on the incoming index, so an adjusted-score tie resolves to exactly the
|
|
1479
|
+
// order built here (score → evidence → dzId), by construction, on any engine. That generic
|
|
1480
|
+
// function only knows `score`, so it cannot call compareHybridHits itself.
|
|
1481
|
+
const ordered = orderHitsForReRank(hits, idOf);
|
|
1482
|
+
const candidates = ordered.map((h) => {
|
|
1360
1483
|
const dzId = idOf(h.pattern);
|
|
1361
1484
|
const rec = idToRecord.get(dzId);
|
|
1362
1485
|
return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
|
|
@@ -1382,8 +1505,8 @@ export async function recallHybrid(
|
|
|
1382
1505
|
? []
|
|
1383
1506
|
: [{ id: 'delta', byIndex: candidates.map((c) => deltaMap.get(c.dzId) ?? 0), cap: REINFORCE_RRF_CAP }];
|
|
1384
1507
|
// The SAME ranking without the payoff term — the only honest way to say what the term moved.
|
|
1385
|
-
const before = dampQuarantined(applyLearningSignalsWithTerms(
|
|
1386
|
-
const after = dampQuarantined(applyLearningSignalsWithTerms(
|
|
1508
|
+
const before = dampQuarantined(applyLearningSignalsWithTerms(ordered, learning, candidates, REINFORCE_RRF_CAP, baseTerms));
|
|
1509
|
+
const after = dampQuarantined(applyLearningSignalsWithTerms(ordered, learning, candidates, REINFORCE_RRF_CAP, [
|
|
1387
1510
|
...baseTerms,
|
|
1388
1511
|
// ADDED, never assigned, and pre-bounded to [-1,+1] by the ACL — so `squash` is identity and
|
|
1389
1512
|
// `cap` is an EXACT bound on this term's contribution (INV-4).
|
|
@@ -1413,9 +1536,9 @@ export async function recallHybrid(
|
|
|
1413
1536
|
}
|
|
1414
1537
|
if (deltaMap !== undefined) {
|
|
1415
1538
|
const deltaByIndex = candidates.map((c) => deltaMap.get(c.dzId) ?? 0);
|
|
1416
|
-
return dampQuarantined(applyLearningSignalsWithDelta(
|
|
1539
|
+
return dampQuarantined(applyLearningSignalsWithDelta(ordered, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
|
|
1417
1540
|
}
|
|
1418
|
-
return dampQuarantined(applyLearningSignals(
|
|
1541
|
+
return dampQuarantined(applyLearningSignals(ordered, learning, candidates, REINFORCE_RRF_CAP));
|
|
1419
1542
|
};
|
|
1420
1543
|
/** The exposure/telemetry payload for `markRecallHits` — `undefined` while disarmed (INV-1). */
|
|
1421
1544
|
const banditEmission = (): { readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number; readonly deferred?: boolean } | undefined =>
|