@dzhechkov/harness-core 0.3.148 → 0.3.150

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/dist/reqe.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * re-QE debt — the pure half of `dz reqe` (backlog 6b40e667, goal honest-quality).
3
+ *
4
+ * The cross-model-QE guard ("the model that writes code must not self-review") is an ADR-named
5
+ * safety property of the feature-adr pipeline. The usage-adaptive override consciously SUSPENDS it
6
+ * (FR-2.9): at >= threshold usage every remaining stage — including Step-8 QE — switches to Codex,
7
+ * so coder and reviewer become the SAME family. The rule doc said "run an independent re-QE after
8
+ * limits reset" — a human instruction on the weakest detection layer. This module turns it into a
9
+ * DEBT with a lifecycle:
10
+ *
11
+ * emit — the workflow records features/<slug>/.fa-state/reqe-due.json when Step-8 actually ran
12
+ * same-family under the override (not on every switch — a switch before Step 8 that
13
+ * still got cross-family QE creates no debt);
14
+ * list — `dz reqe` scans the debts; `dz usage` surfaces the count so the moment limits free up
15
+ * is the moment the debt is visible;
16
+ * brief — `dz reqe --slug <s>` prints a ready cross-family review brief (the OTHER family than
17
+ * the coder);
18
+ * settle — `dz reqe --slug <s> --done --report <file>` clears the debt FAIL-CLOSED: only against
19
+ * an existing, non-trivial report that names a grade; the settlement is appended to
20
+ * 08_qe_report.md so the artifact trail closes.
21
+ *
22
+ * HONEST SCOPE: nothing here re-runs QE automatically (no background spend — the human decides);
23
+ * the same-family CLAUDE belt fallback (codex unavailable) is out of scope by design — it is
24
+ * already logged loudly at run time and is not a limit-pressure artifact; runs from before this
25
+ * feature carry no marker and are UNDETERMINABLE, not debt-free.
26
+ */
27
+ export const REQE_SCHEMA = 'reqe-due-1';
28
+ export const REQE_SCOPE = 'scope: debt is emitted only when Step-8 QE actually ran on the coder’s own family under the ' +
29
+ 'usage override; settling requires a graded cross-family report (fail-closed); nothing re-runs QE ' +
30
+ 'automatically.';
31
+ /** Family classification shared with the workflow's acFamOf (codex/gpt/openai markers ⇒ openai).
32
+ * DELIBERATELY binary over the workflow's own CONTROLLED vocabulary (coderUsed ∈ claude | codex |
33
+ * codex-fallback; qeReviewerUsed ∈ claude | codex) — this is never fed arbitrary model ids, so the
34
+ * claude default is the correct reading of "not a codex marker", not a fail-open (Codex QE #11,
35
+ * accepted with this documentation). */
36
+ export function modelFamily(spec) {
37
+ return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'openai' : 'claude';
38
+ }
39
+ /** Emit iff the QE stage label carries the workflow's ' (usage-switched)' marker AND the reviewer
40
+ * family equals the coder family. Marker-only (cross-family survived the switch) or same-family
41
+ * WITHOUT the marker (the codex-unavailable Claude belt — degraded loudly at run time, not a
42
+ * limit-pressure artifact) both create NO debt. */
43
+ export function shouldEmitReqeDebt(input) {
44
+ const switched = /\(usage-switched\)/.test(String(input.qeModelLabel ?? ''));
45
+ const coderFam = modelFamily(input.coderUsed);
46
+ const qeFam = modelFamily(input.qeReviewerUsed);
47
+ if (switched && coderFam === qeFam) {
48
+ return {
49
+ emit: true,
50
+ reason: 'usage-switched self-review: Step-8 QE ran on the coder’s own family (' + coderFam +
51
+ ') under the limit override — the cross-model guard was suspended (FR-2.9)',
52
+ };
53
+ }
54
+ if (switched)
55
+ return { emit: false, reason: 'usage-switched but QE stayed cross-family (' + qeFam + ' vs coder ' + coderFam + ')' };
56
+ if (coderFam === qeFam)
57
+ return { emit: false, reason: 'same-family without the usage override (belt degrade — logged loudly at run time; out of re-QE-debt scope)' };
58
+ return { emit: false, reason: 'cross-family QE ran' };
59
+ }
60
+ /** Build the debt record (the workflow serializes this; emittedAt is stamped by the writer agent's
61
+ * shell `date`, so the sandbox needs no Date). */
62
+ export function buildReqeDebt(input) {
63
+ return {
64
+ schema: REQE_SCHEMA,
65
+ slug: input.slug,
66
+ coderFamily: modelFamily(input.coderUsed),
67
+ qeFamily: modelFamily(input.qeReviewerUsed),
68
+ qeGrade: input.qeGrade == null || String(input.qeGrade).trim() === '' ? null : String(input.qeGrade).trim(),
69
+ reason: input.reason,
70
+ emittedAt: input.emittedAt ?? null,
71
+ };
72
+ }
73
+ /** Parse + validate a debt file's text. null = not a valid debt (the caller reports it as
74
+ * malformed — a corrupt debt file is NAMED, never silently dropped). */
75
+ export function parseReqeDebt(text) {
76
+ let raw;
77
+ try {
78
+ raw = JSON.parse(String(text ?? ''));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ const d = raw;
84
+ if (!d || typeof d !== 'object')
85
+ return null;
86
+ if (d.schema !== REQE_SCHEMA)
87
+ return null;
88
+ if (typeof d.slug !== 'string' || d.slug.trim() === '')
89
+ return null;
90
+ if (d.coderFamily !== 'claude' && d.coderFamily !== 'openai')
91
+ return null;
92
+ if (d.qeFamily !== 'claude' && d.qeFamily !== 'openai')
93
+ return null;
94
+ if (typeof d.reason !== 'string' || d.reason.trim() === '')
95
+ return null;
96
+ return {
97
+ schema: REQE_SCHEMA,
98
+ slug: d.slug,
99
+ coderFamily: d.coderFamily,
100
+ qeFamily: d.qeFamily,
101
+ qeGrade: typeof d.qeGrade === 'string' && d.qeGrade.trim() !== '' ? d.qeGrade.trim() : null,
102
+ reason: d.reason,
103
+ emittedAt: typeof d.emittedAt === 'string' && d.emittedAt.trim() !== '' ? d.emittedAt : null,
104
+ runStamp: typeof d.runStamp === 'string' && d.runStamp.trim() !== '' ? d.runStamp : null,
105
+ };
106
+ }
107
+ /** The ready-to-run cross-family review brief. Review family = the OTHER family than the CODER
108
+ * (reviewing with the other-than-reviewer family would let a codex-coded, codex-reviewed run be
109
+ * "re-reviewed" by codex again). */
110
+ export function buildReqeBrief(debt, artifactsDir) {
111
+ const reviewFamily = debt.coderFamily === 'openai' ? 'claude' : 'openai';
112
+ const files = [
113
+ artifactsDir + '/07_code_changes/change_manifest.md',
114
+ artifactsDir + '/08_qe_report.md',
115
+ artifactsDir + '/03_adr/',
116
+ ];
117
+ const instructions = [
118
+ 'Independent re-QE for "' + debt.slug + '": the recorded Step-8 review ran on the coder’s own family (' + debt.coderFamily + ') under the usage override' + (debt.qeGrade ? ' and graded ' + debt.qeGrade : '') + '.',
119
+ 'Review with the ' + reviewFamily.toUpperCase() + ' family (the OTHER family than the coder — the suspended guard, restored).',
120
+ 'Read: ' + files.join(' , ') + ' plus every file the change manifest lists.',
121
+ 'Adversarially verify: correctness, edge cases, the ADR-named load-bearing property HAS a test, and whether the same-family review missed anything.',
122
+ 'Output: GRADE A-F + numbered findings with file:line and severity; write the report to ' + artifactsDir + '/08b_reqe_report.md.',
123
+ 'Then settle the debt: dz reqe --slug ' + debt.slug + ' --done --report ' + artifactsDir + '/08b_reqe_report.md',
124
+ ];
125
+ const codexCmdTemplate = reviewFamily === 'openai'
126
+ ? 'codex exec -m <probed-id> -c model_reasoning_effort="high" --sandbox read-only "<the brief above>" < /dev/null # probe the id first: ids are account-specific'
127
+ : null;
128
+ return {
129
+ reviewFamily,
130
+ header: 're-QE brief for ' + debt.slug + ' (' + (debt.emittedAt ?? 'emitted: unknown') + ')',
131
+ instructions,
132
+ codexCmdTemplate,
133
+ };
134
+ }
135
+ /** Extract the verdict grade from a report, or null. LINE-ANCHORED and range-proof (Codex QE #7):
136
+ * the boilerplate phrase `GRADE A-F` must not read as grade A, so a letter followed by a dash and
137
+ * another grade letter is rejected; and the grade must head its line (a quoted "do not assign
138
+ * GRADE A" mid-paragraph is not a verdict). Conflicting distinct grades ⇒ null (ambiguous). */
139
+ export function extractReportGrade(text) {
140
+ // the lookahead rejects RANGES in punctuation form (A-F, A/F) AND word form (A through F,
141
+ // A to F) — Codex QE round-2 #6: 'GRADE A through F' must not read as grade A
142
+ const matches = [...String(text ?? '').matchAll(/^\s*(?:\*{0,2}#{0,4}\s*)?GRADE\s*[:=—–-]?\s*([A-F])\b(?!\s*(?:[-–—/]|through|to|thru)\s*[A-F]\b)/gim)];
143
+ const distinct = new Set(matches.map((m) => (m[1] ?? '').toUpperCase()).filter((g) => g !== ''));
144
+ if (distinct.size !== 1)
145
+ return null;
146
+ return [...distinct][0] ?? null;
147
+ }
148
+ /** FAIL-CLOSED settlement validation: the report must be non-trivial (>= 200 chars of substance)
149
+ * and must NAME exactly one line-anchored grade. A settlement that cannot cite its evidence is
150
+ * refused — clearing a debt against an empty file would re-open the exact hole this feature closes.
151
+ * HONEST LIMIT (documented, not hidden): the validator proves the settlement is PROCEDURALLY sound
152
+ * (a distinct, graded report exists); it cannot prove which model authored the text — attribution
153
+ * stays with the human running the brief. */
154
+ export function settleReqeDebt(debt, reportText, reportPath) {
155
+ const text = String(reportText ?? '');
156
+ if (text.trim().length < 200) {
157
+ return { ok: false, error: 'report too small to be a review (< 200 chars of substance) — refusing to settle', grade: null, epilogue: null };
158
+ }
159
+ const grade = extractReportGrade(text);
160
+ if (grade === null) {
161
+ return { ok: false, error: 'report names no unambiguous line-anchored GRADE (A-F) — text without exactly one verdict grade is not a verdict; refusing to settle', grade: null, epilogue: null };
162
+ }
163
+ const reviewFamily = debt.coderFamily === 'openai' ? 'claude' : 'openai';
164
+ const epilogue = [
165
+ '',
166
+ '---',
167
+ '',
168
+ '## re-QE settlement (cross-model debt cleared)',
169
+ '',
170
+ 'The original Step-8 review ran on the coder’s own family (' + debt.coderFamily + ') under the',
171
+ 'usage override (' + debt.reason + '). An independent ' + reviewFamily.toUpperCase() + '-family re-QE was performed:',
172
+ '',
173
+ '- report: `' + reportPath + '`',
174
+ '- re-QE grade: **' + grade + '**' + (debt.qeGrade ? ' (same-family grade on record: ' + debt.qeGrade + ')' : ''),
175
+ '- settled via `dz reqe --done` (fail-closed: an existing graded report is required).',
176
+ '',
177
+ ].join('\n');
178
+ return { ok: true, error: null, grade, epilogue };
179
+ }
180
+ /** Render the debt list for `dz reqe` / the `dz usage` surfacing line. */
181
+ export function renderReqeList(debts, malformed) {
182
+ const lines = [];
183
+ if (debts.length === 0 && malformed === 0) {
184
+ lines.push('dz reqe: no re-QE debts — every recorded run kept cross-model QE (or none used the usage override).');
185
+ }
186
+ else {
187
+ lines.push('dz reqe — ' + debts.length + ' unsettled re-QE debt(s):');
188
+ for (const d of debts) {
189
+ lines.push(' ' + d.slug + ' coder=' + d.coderFamily + ' qe=' + d.qeFamily + (d.qeGrade ? ' grade=' + d.qeGrade : '') + (d.emittedAt ? ' ' + d.emittedAt : '') + ' → dz reqe --slug ' + d.slug);
190
+ }
191
+ }
192
+ if (malformed > 0)
193
+ lines.push(' ' + malformed + ' malformed debt file(s) skipped (named, never silent).');
194
+ lines.push(REQE_SCOPE);
195
+ return lines;
196
+ }
197
+ //# sourceMappingURL=reqe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reqe.js","sourceRoot":"","sources":["../src/reqe.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/harness-core",
3
- "version": "0.3.148",
3
+ "version": "0.3.150",
4
4
  "description": "Shared harness logic - skill loading, additive apply, and the init/sync/verify/doctor operations.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,11 +32,11 @@
32
32
  "@dzhechkov/adapter-openclaude": "^0.1.0",
33
33
  "@dzhechkov/adapter-opencode": "^0.2.0",
34
34
  "yaml": "^2.0.0",
35
- "@dzhechkov/adapter-copilot": "0.1.1",
35
+ "@dzhechkov/adapter-agents-md": "0.1.1",
36
36
  "@dzhechkov/adapter-cursor": "0.1.1",
37
- "@dzhechkov/adapter-windsurf": "0.1.1",
38
37
  "@dzhechkov/adapter-gemini": "0.1.1",
39
- "@dzhechkov/adapter-agents-md": "0.1.1",
38
+ "@dzhechkov/adapter-copilot": "0.1.1",
39
+ "@dzhechkov/adapter-windsurf": "0.1.1",
40
40
  "@dzhechkov/core": "0.2.14",
41
41
  "@dzhechkov/memory": "0.2.9"
42
42
  },
package/sbom.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "4710903a3f0f81d2163062977f6471f29073d2c516209f875c09cc54e75ea099"
28
+ "content": "6d047e89ebd5532a54d5200fa86660b3a8f1e96e128a5a4042d718d2cacae2ec"
29
29
  }
30
30
  ]
31
31
  },
@@ -1295,7 +1295,7 @@
1295
1295
  "hashes": [
1296
1296
  {
1297
1297
  "alg": "SHA-256",
1298
- "content": "ecf62c0372ef24516aa12797719b0cedfbfea1cf0f92d573baea8e9d9dea45b8"
1298
+ "content": "1651978c5ce1fc510541d6ae3cc09f9cd74dde1e31d460d01b16f637dd80c8d2"
1299
1299
  }
1300
1300
  ]
1301
1301
  },
@@ -1305,7 +1305,7 @@
1305
1305
  "hashes": [
1306
1306
  {
1307
1307
  "alg": "SHA-256",
1308
- "content": "ef7b4183ebdd16a162e00960b8070207901d6d3b4415c2ff78da0a762e713a78"
1308
+ "content": "a8098db1327beb284a22ac8b2410d030578e55a0648c8c8fbc7934dfa966ef85"
1309
1309
  }
1310
1310
  ]
1311
1311
  },
@@ -1315,7 +1315,7 @@
1315
1315
  "hashes": [
1316
1316
  {
1317
1317
  "alg": "SHA-256",
1318
- "content": "25595cd5c6d53c4376571a9bf52444778a749fc22f50c64d15c38b248c12ed1f"
1318
+ "content": "46032dd7deeb29b7a71d13d068620bd197a9a4d503e2c74089cbff0b9314859e"
1319
1319
  }
1320
1320
  ]
1321
1321
  },
@@ -1325,7 +1325,7 @@
1325
1325
  "hashes": [
1326
1326
  {
1327
1327
  "alg": "SHA-256",
1328
- "content": "c07373c4a1ad8ec473dcd01852cbffc6b5641e78bbf629dd8b5060dda94eea5e"
1328
+ "content": "ad9870713ae8589d93e031048a9af4cea0371a35b53194870a672d02e66ccaaa"
1329
1329
  }
1330
1330
  ]
1331
1331
  },
@@ -2009,6 +2009,46 @@
2009
2009
  }
2010
2010
  ]
2011
2011
  },
2012
+ {
2013
+ "type": "file",
2014
+ "name": "dist/reqe.d.ts",
2015
+ "hashes": [
2016
+ {
2017
+ "alg": "SHA-256",
2018
+ "content": "115fa0797ad0e98a452f9987c15b038e22b18486043c8890c65cc32c9dc51af9"
2019
+ }
2020
+ ]
2021
+ },
2022
+ {
2023
+ "type": "file",
2024
+ "name": "dist/reqe.d.ts.map",
2025
+ "hashes": [
2026
+ {
2027
+ "alg": "SHA-256",
2028
+ "content": "75996953748647f0cbe548c73daa5cb7cd1f8906f2df474926c31b9e5fa223f9"
2029
+ }
2030
+ ]
2031
+ },
2032
+ {
2033
+ "type": "file",
2034
+ "name": "dist/reqe.js",
2035
+ "hashes": [
2036
+ {
2037
+ "alg": "SHA-256",
2038
+ "content": "229014a8fa37faeb716d7f0deb22758a133ce50479b849b072eb6a285e78db40"
2039
+ }
2040
+ ]
2041
+ },
2042
+ {
2043
+ "type": "file",
2044
+ "name": "dist/reqe.js.map",
2045
+ "hashes": [
2046
+ {
2047
+ "alg": "SHA-256",
2048
+ "content": "c28182b280cf2c016a2b1d2ef15a3a335ff25993220767ae9fc7c74af76ba022"
2049
+ }
2050
+ ]
2051
+ },
2012
2052
  {
2013
2053
  "type": "file",
2014
2054
  "name": "dist/risk-scoring.d.ts",
@@ -2775,7 +2815,7 @@
2775
2815
  "hashes": [
2776
2816
  {
2777
2817
  "alg": "SHA-256",
2778
- "content": "1a64e1ded35c482214c3426808b24bcf7048041d716e375f101e228931c28248"
2818
+ "content": "76997cf27ff9c8901df35b45fbdb1c91f201396f070dab0579289b8aa9ac851c"
2779
2819
  }
2780
2820
  ]
2781
2821
  },
@@ -3095,7 +3135,7 @@
3095
3135
  "hashes": [
3096
3136
  {
3097
3137
  "alg": "SHA-256",
3098
- "content": "537dc4dc55cb43f11f2f0cee45ea53ec6fe362df470948408c49bb11fe972d9f"
3138
+ "content": "5eb07fb5e21e7be1e78a11243d6f86b9c84461eec22a954102549b5e237b7ab2"
3099
3139
  }
3100
3140
  ]
3101
3141
  },
@@ -3259,6 +3299,16 @@
3259
3299
  }
3260
3300
  ]
3261
3301
  },
3302
+ {
3303
+ "type": "file",
3304
+ "name": "src/reqe.ts",
3305
+ "hashes": [
3306
+ {
3307
+ "alg": "SHA-256",
3308
+ "content": "67988858ff527e5b465aa46b341aa710465e8cb0f72038be30f2419e0ff12cc3"
3309
+ }
3310
+ ]
3311
+ },
3262
3312
  {
3263
3313
  "type": "file",
3264
3314
  "name": "src/risk-scoring.ts",
@@ -4109,6 +4159,16 @@
4109
4159
  }
4110
4160
  ]
4111
4161
  },
4162
+ {
4163
+ "type": "file",
4164
+ "name": "test/reqe.test.ts",
4165
+ "hashes": [
4166
+ {
4167
+ "alg": "SHA-256",
4168
+ "content": "c5bb25b04caa8e9c14111ab838a135f7e2564b9fd1b7624520827bc95600661e"
4169
+ }
4170
+ ]
4171
+ },
4112
4172
  {
4113
4173
  "type": "file",
4114
4174
  "name": "test/routing-outcomes.test.ts",
package/src/index.ts CHANGED
@@ -155,6 +155,30 @@ export {
155
155
  checkpointAppendCmd,
156
156
  } from './feature-adr-checkpoints.js';
157
157
  export type { CheckpointStage, CheckpointEntry, ResumeMode, ResumeDecision, ParsedCheckpointRead } from './feature-adr-checkpoints.js';
158
+ export {
159
+ DOMAIN_LIFT_EXACT,
160
+ DOMAIN_LIFT_RELATED,
161
+ normalizeDomain,
162
+ domainMatch,
163
+ applyDomainBoost,
164
+ countDisplacedByCut,
165
+ renderDomainBoostNote,
166
+ renderDomainCutNote,
167
+ } from './recall-domain-boost.js';
168
+ export type { DomainMatch, DomainBoostResult } from './recall-domain-boost.js';
169
+ export {
170
+ REQE_SCHEMA,
171
+ REQE_SCOPE,
172
+ modelFamily,
173
+ shouldEmitReqeDebt,
174
+ buildReqeDebt,
175
+ parseReqeDebt,
176
+ buildReqeBrief,
177
+ extractReportGrade,
178
+ settleReqeDebt,
179
+ renderReqeList,
180
+ } from './reqe.js';
181
+ export type { ModelFamily, ReqeEmitDecision, ReqeDebt, ReqeBrief, ReqeSettlement } from './reqe.js';
158
182
  export {
159
183
  detectQueryLang,
160
184
  relevanceFloorFor,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Domain-aware recall re-ranking — the pure half of `dz recall --domain <name>`.
3
+ *
4
+ * WHY THIS EXISTS. `dz teach --domain <name>` has always recorded a domain, and
5
+ * `dz recall --all --stats` prints the per-domain histogram — but the RANKING path
6
+ * (`recallHybrid`: lexical FTS5 + optional vector leg merged by RRF) never looked at
7
+ * the field. So a store shared by different KINDS of work returns them interleaved:
8
+ * medical-research lessons dilute a coding recall and vice versa, and the dilution
9
+ * gets worse as either side grows.
10
+ *
11
+ * WHY A BOOST AND NOT A FILTER. Cross-pollination here is real, not theoretical:
12
+ * "a reviewer's evidence needs the same execute-don't-describe discipline as your
13
+ * own claims" was learned reviewing code and applies verbatim to medical sources;
14
+ * the medical report's core insight ("was the source ever opened?") is the same
15
+ * principle as our claim-check. A hard filter would cut exactly the transfers that
16
+ * make one store worth more than two. So: matching-domain hits move UP, foreign
17
+ * ones stay in the list.
18
+ *
19
+ * THE PROMISE, NARROWLY: this reorders. It NEVER drops a hit, never invents one,
20
+ * and never changes how many are returned. If the boost is wrong, the cost is
21
+ * ordering; it can't hide a lesson from you.
22
+ */
23
+
24
+ import type { RecallHit } from './patterns.js';
25
+
26
+ /** How many places a same-domain lesson may climb. STATED AS POSITIONS, not as a
27
+ * score multiplier: "moves up at most two places" is a sentence a reader can check
28
+ * against the output, while "index × 0.55" is an opaque constant whose behaviour
29
+ * changes with position (it could not lift index 2 past index 1 at all, but did
30
+ * lift index 3 past index 2 — an accident, not a design). A bounded lift keeps
31
+ * lexical relevance dominant: a domain tag is a hint about relevance, not evidence
32
+ * of it, so it breaks near-ties instead of overruling the ranking. */
33
+ export const DOMAIN_LIFT_EXACT = 2;
34
+
35
+ /** A prefix/suffix relative (`health-research` against `health`) climbs less. */
36
+ export const DOMAIN_LIFT_RELATED = 1;
37
+
38
+ export type DomainMatch = 'exact' | 'related' | 'none';
39
+
40
+ /** Normalize a domain tag for comparison: case- and separator-insensitive, so
41
+ * `Health-Research`, `health_research` and `health research` are one domain. */
42
+ export function normalizeDomain(domain: string | null | undefined): string {
43
+ return String(domain ?? '')
44
+ .toLowerCase()
45
+ .replace(/[\s_]+/g, '-')
46
+ .replace(/^-+|-+$/g, '');
47
+ }
48
+
49
+ /** How a hit's domain relates to the requested one. `related` covers the common
50
+ * hierarchy shapes (`health` ↔ `health-research`) without a taxonomy: a taxonomy
51
+ * nobody maintains drifts, and a wrong taxonomy is worse than none. */
52
+ export function domainMatch(hitDomain: string | null | undefined, wanted: string | null | undefined): DomainMatch {
53
+ const a = normalizeDomain(hitDomain);
54
+ const b = normalizeDomain(wanted);
55
+ if (a === '' || b === '') return 'none';
56
+ if (a === b) return 'exact';
57
+ if (a.startsWith(`${b}-`) || b.startsWith(`${a}-`)) return 'related';
58
+ return 'none';
59
+ }
60
+
61
+ export interface DomainBoostResult {
62
+ readonly hits: readonly RecallHit[];
63
+ /** How many hits MATCHED the domain (exactly / relatedly). */
64
+ readonly exact: number;
65
+ readonly related: number;
66
+ /** How many hits actually CHANGED position. Counted separately because the note
67
+ * used to print the match counts as "lifted" — an exact match already at rank 0,
68
+ * or a list that is entirely one domain, matches without moving, and calling that
69
+ * "lifted" tells the reader the ranking changed when it did not. */
70
+ readonly moved: number;
71
+ /** True when NOTHING in the result matched: the caller says so out loud rather
72
+ * than implying the ranking is domain-aware when it had nothing to work with. */
73
+ readonly noMatches: boolean;
74
+ }
75
+
76
+ /**
77
+ * Re-rank hits so same-domain lessons surface first, keeping every hit.
78
+ *
79
+ * Effective position = `index - lift`. The original relevance order dominates; the
80
+ * domain only breaks near-ties, and the lift is BOUNDED so a tail match can never
81
+ * teleport to the top. Ties keep the incoming order (stable sort), so the function
82
+ * is deterministic — the same input always yields the same output, which is what
83
+ * makes it testable at all.
84
+ */
85
+ export function applyDomainBoost(hits: readonly RecallHit[], wanted: string | null | undefined): DomainBoostResult {
86
+ const target = normalizeDomain(wanted);
87
+ if (target === '' || hits.length === 0) {
88
+ return { hits, exact: 0, related: 0, moved: 0, noMatches: true };
89
+ }
90
+ let exact = 0;
91
+ let related = 0;
92
+ const scored = hits.map((hit, index) => {
93
+ const match = domainMatch(hit.pattern.domain, target);
94
+ if (match === 'exact') exact += 1;
95
+ else if (match === 'related') related += 1;
96
+ const lift = match === 'exact' ? DOMAIN_LIFT_EXACT : match === 'related' ? DOMAIN_LIFT_RELATED : 0;
97
+ return { hit, index, lift, effective: index - lift };
98
+ });
99
+ // Tie-break: at equal effective position the LIFTED hit goes first, then the
100
+ // original order. Without this the foreign hit it landed level with won the tie
101
+ // by having the lower original index, so a lift of N moved the hit only N-1
102
+ // places — the code quietly delivered less than the constant promised. A test
103
+ // asserting the documented bound caught it; the fix is here, not in the test.
104
+ scored.sort((a, b) => (a.effective - b.effective) || (b.lift - a.lift) || (a.index - b.index));
105
+ const reordered = scored.map((s) => s.hit);
106
+ let moved = 0;
107
+ scored.forEach((s, newIndex) => { if (s.index !== newIndex) moved += 1; });
108
+ return { hits: reordered, exact, related, moved, noMatches: exact === 0 && related === 0 };
109
+ }
110
+
111
+ /**
112
+ * How many hits the CUT hid that an unboosted recall would have shown.
113
+ *
114
+ * WHY THIS EXISTS. `applyDomainBoost` never drops a hit — but the CLI still cuts the
115
+ * list at `--limit`, and a promotion INTO the top N necessarily pushes something out
116
+ * of it. Cross-model review found the resulting sentence to be a lie by omission: the
117
+ * note said "foreign-domain lessons kept" while `foreign-B`, visible without
118
+ * `--domain`, had vanished from the printed output. Both statements were true of
119
+ * different lists, which is exactly how an honest tool ends up misleading its reader.
120
+ *
121
+ * The fix is not to stop promoting — that would be the filter we refused to build. It
122
+ * is to COUNT what fell past the cut and say so, because the reader can act on that
123
+ * (raise `--limit`) only if they know it happened.
124
+ *
125
+ * Compares by identity: both lists hold the same hit objects.
126
+ */
127
+ export function countDisplacedByCut(
128
+ original: readonly RecallHit[],
129
+ boosted: readonly RecallHit[],
130
+ limit: number,
131
+ ): number {
132
+ if (!Number.isFinite(limit) || limit <= 0) return 0;
133
+ const shownAfter = new Set(boosted.slice(0, limit));
134
+ let displaced = 0;
135
+ for (const hit of original.slice(0, limit)) {
136
+ if (!shownAfter.has(hit)) displaced += 1;
137
+ }
138
+ return displaced;
139
+ }
140
+
141
+ /** The line that reports the cut. Empty when nothing was displaced, so the common
142
+ * case stays quiet. */
143
+ export function renderDomainCutNote(displaced: number, limit: number): string {
144
+ if (displaced <= 0) return '';
145
+ const plural = displaced === 1 ? 'lesson' : 'lessons';
146
+ return ` ${displaced} lower-ranked ${plural} fell past the --limit ${limit} cut to make room — raise --limit to see them (the boost promotes; the cut is what hides)`;
147
+ }
148
+
149
+ /** One honest line about what the boost did — including the case where it did
150
+ * nothing, which a silent reorder would hide.
151
+ *
152
+ * The two tail phrases below are a WIRE CONTRACT: `learning_bridge.py` in the
153
+ * `goap-research-ed25519` skill detects whether the installed `dz` supports
154
+ * `--domain` by looking for them, because an older CLI ignores the flag and exits 0
155
+ * (so an exit code cannot tell the versions apart). A test pins both strings — if you
156
+ * reword them, update the bridge in the same change or you switch that loop into
157
+ * permanent degraded mode without a single test turning red. */
158
+ export function renderDomainBoostNote(result: DomainBoostResult, wanted: string): string {
159
+ if (result.noMatches) {
160
+ return ` domain "${wanted}": no lesson in this result carries it — order unchanged, nothing was hidden`;
161
+ }
162
+ const parts = [`${result.exact} exact`];
163
+ if (result.related > 0) parts.push(`${result.related} related`);
164
+ const effect = result.moved === 0
165
+ ? 'the order was already correct — nothing moved'
166
+ : `${result.moved} changed position`;
167
+ // The counts describe the CANDIDATES the boost ranked, which is a longer list than
168
+ // the one printed (the caller over-fetches, then cuts at --limit). Saying "3 changed
169
+ // position" above two printed lines reads as an arithmetic error unless the note
170
+ // names the list it is talking about — so it does.
171
+ return ` domain "${wanted}": among ${result.hits.length} candidate(s) — ${parts.join(', ')} match(es), ${effect}; foreign-domain lessons kept (a boost, not a filter)`;
172
+ }