@dzhechkov/harness-core 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.dz-manifest.json +101 -41
  2. package/README.md +1 -1
  3. package/dist/event-chain.d.ts +50 -0
  4. package/dist/event-chain.d.ts.map +1 -1
  5. package/dist/event-chain.js +31 -0
  6. package/dist/event-chain.js.map +1 -1
  7. package/dist/index.d.ts +8 -5
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -3
  10. package/dist/index.js.map +1 -1
  11. package/dist/name-check.d.ts +98 -0
  12. package/dist/name-check.d.ts.map +1 -0
  13. package/dist/name-check.js +333 -0
  14. package/dist/name-check.js.map +1 -0
  15. package/dist/operations.d.ts.map +1 -1
  16. package/dist/operations.js +25 -4
  17. package/dist/operations.js.map +1 -1
  18. package/dist/provenance.d.ts +100 -92
  19. package/dist/provenance.d.ts.map +1 -1
  20. package/dist/provenance.js +122 -122
  21. package/dist/provenance.js.map +1 -1
  22. package/dist/recall-domain-boost.d.ts +10 -3
  23. package/dist/recall-domain-boost.d.ts.map +1 -1
  24. package/dist/recall-domain-boost.js +7 -0
  25. package/dist/recall-domain-boost.js.map +1 -1
  26. package/dist/recall-hook-policy.d.ts +15 -0
  27. package/dist/recall-hook-policy.d.ts.map +1 -1
  28. package/dist/recall-hook-policy.js +59 -0
  29. package/dist/recall-hook-policy.js.map +1 -1
  30. package/dist/recap.d.ts +146 -0
  31. package/dist/recap.d.ts.map +1 -0
  32. package/dist/recap.js +346 -0
  33. package/dist/recap.js.map +1 -0
  34. package/dist/retro.d.ts +131 -0
  35. package/dist/retro.d.ts.map +1 -0
  36. package/dist/retro.js +207 -0
  37. package/dist/retro.js.map +1 -0
  38. package/dist/score.d.ts +21 -0
  39. package/dist/score.d.ts.map +1 -1
  40. package/dist/score.js +44 -3
  41. package/dist/score.js.map +1 -1
  42. package/dist/vector-tier.d.ts +54 -0
  43. package/dist/vector-tier.d.ts.map +1 -1
  44. package/dist/vector-tier.js +69 -8
  45. package/dist/vector-tier.js.map +1 -1
  46. package/package.json +5 -5
  47. package/sbom.json +190 -40
  48. package/src/event-chain.ts +64 -0
  49. package/src/index.ts +12 -0
  50. package/src/name-check.ts +331 -0
  51. package/src/operations.ts +26 -5
  52. package/src/provenance.ts +217 -0
  53. package/src/recall-domain-boost.ts +10 -3
  54. package/src/recall-hook-policy.ts +60 -0
  55. package/src/recap.ts +462 -0
  56. package/src/score.ts +53 -3
  57. package/src/vector-tier.ts +109 -10
@@ -180,3 +180,63 @@ export function renderHookContext(selection: HookSelection): string {
180
180
  const lines = selection.hits.map((h) => ` - [${h.score.toFixed(2)}${h.domain ? ` / ${h.domain}` : ''}] ${h.pattern}`);
181
181
  return `Learned lessons that match this prompt (dz recall, relevance ≥ ${selection.floor.toFixed(2)}):\n${lines.join('\n')}`;
182
182
  }
183
+
184
+ /**
185
+ * The closeness token for one recall row: `sim=0.67▲` above the floor, `sim=0.29▽` below it,
186
+ * `sim=—` when closeness was not measured for this row.
187
+ *
188
+ * The marker is the floor comparison DONE FOR THE READER, against the same per-language floors the
189
+ * hook already trusts — calibrated 2026-07-09 on a 32-probe labeled set, not chosen by feel. A bare
190
+ * cosine means nothing without that table, and the point of this token is a number the reader can
191
+ * act on: `▲` is on-topic, `▽` is "ranked because something had to rank first", `—` is "unmeasured".
192
+ *
193
+ * A lexical-only hit and an engine whose score is not a cosine both get the dash. Substituting a
194
+ * differently-scaled number there is the precise lie this exists to remove.
195
+ */
196
+ export function closenessLine(similarity: number | undefined, query: unknown, floors?: Partial<RecallFloors>): string {
197
+ if (typeof similarity !== 'number' || !isFinite(similarity)) return 'sim=—';
198
+ const floor = relevanceFloorFor(query, floors);
199
+ return `sim=${showCloseness(similarity, floor)}${similarity >= floor ? '▲' : '▽'}`;
200
+ }
201
+
202
+ /**
203
+ * The cosine, printed with enough digits that it cannot LOOK equal to the floor when it is not.
204
+ *
205
+ * Two rounds of cross-family review (codex `gpt-5.6-sol`, 2026-08-24) landed on opposite sides of one
206
+ * trade-off, and both were right. Comparing before rounding shows `sim=0.50▽` for 0.499 against a
207
+ * 0.50 floor — a figure equal to the floor, marked below it. Comparing after rounding shows
208
+ * `sim=0.50▲` for the same input — a marker that says on-topic for a value the calibrated floor
209
+ * excludes. Choosing a side cannot fix this, because the contradiction lives in the DISPLAY, not in
210
+ * the comparison: two decimals cannot always distinguish a value from its floor.
211
+ *
212
+ * So the comparison stays on the true value — the floors are calibrated on true cosines — and the
213
+ * display widens until the number visibly differs from the floor. 0.499 against 0.50 prints as
214
+ * `sim=0.499▽`: below the floor, and visibly so.
215
+ */
216
+ function showCloseness(similarity: number, floor: number): string {
217
+ // Widen until the number and the FLOOR differ AS DISPLAYED at the same precision. Comparing the
218
+ // shown value against the raw floor was not enough: 0.5009 against a floor of 0.501 stopped at two
219
+ // digits and printed `0.50`, which reads as the floor once the floor is itself rounded for a human
220
+ // (cross-family review round 3, 2026-08-24).
221
+ for (let digits = 2; digits <= 6; digits++) {
222
+ const shown = similarity.toFixed(digits);
223
+ if (similarity === floor || shown !== floor.toFixed(digits)) return shown;
224
+ }
225
+ // Even at full precision the two render alike — 0.4999999 and 0.5 both print `0.500000`, and the
226
+ // contradiction the widening exists to remove survives the cap (cross-family review round 4,
227
+ // 2026-08-24). At that distance the digits are not the answer: state the RELATION instead. The
228
+ // reader learns "just below the floor", which is exactly what is true and what the marker says.
229
+ // The floor is stated AS IT IS, never rounded: with a floor of 0.4999999 a value of 0.49999995 is
230
+ // above the floor and below 0.50, so `>0.50` was literally false (cross-family review round 5,
231
+ // 2026-08-24). `String(floor)` prints exactly what the comparison used.
232
+ return `${similarity < floor ? '<' : '>'}${String(floor)}`;
233
+ }
234
+
235
+ /** Did anything clear the floor? Used to say so ONCE, in words, instead of per row. */
236
+ export function anyAboveFloor(similarities: readonly (number | undefined)[], query: unknown, floors?: Partial<RecallFloors>): boolean {
237
+ const floor = relevanceFloorFor(query, floors);
238
+ // The TRUE value, matching what each row's marker uses — the footer must never contradict the
239
+ // markers above it, and "nothing clears the floor" printed under a visible ▲ would be worse than
240
+ // either alone.
241
+ return similarities.some((s) => typeof s === 'number' && isFinite(s) && s >= floor);
242
+ }
package/src/recap.ts ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * `dz recap` — what was done over a day, a week or a month.
3
+ *
4
+ * This module is PURE: no filesystem, no network, and — load-bearing — no clock. The window arrives
5
+ * as a parameter, because a window you cannot pin in a test is a window whose arithmetic errors you
6
+ * cannot catch. MEASURED in this project 2026-08-22: an agent's timestamp was off by a YEAR, the
7
+ * query returned 15 530 "hits" for a "week", and nothing about the output looked wrong.
8
+ *
9
+ * Its second job is refusing. Half of an honest report is declining to answer what the data cannot
10
+ * support, and those refusals live here as behaviour — types that cannot express a quarter, a
11
+ * decision function that names the real span in days, and a three-state section verdict where
12
+ * "the source said nothing" and "the source was not read" can never collapse into one zero.
13
+ *
14
+ * See features/dz-recap/03_adr/ for the decisions and the measurements behind them.
15
+ */
16
+
17
+ /** The only horizons this project has the data for. A quarter or a year is NOT SPELLABLE (ADR-001). */
18
+ export type RecapHorizon = 'day' | 'week' | 'month';
19
+
20
+ /** Horizons a user may ASK for — recognised so they are refused loudly, never swallowed. */
21
+ export type RefusedHorizon = 'quarter' | 'half-year' | 'year';
22
+
23
+ export const REFUSED_HORIZONS: readonly RefusedHorizon[] = ['quarter', 'half-year', 'year'];
24
+
25
+ export const RECAP_HORIZONS: readonly RecapHorizon[] = ['day', 'week', 'month'];
26
+
27
+ const HORIZON_DAYS: Readonly<Record<RecapHorizon, number>> = { day: 1, week: 7, month: 30 };
28
+ const REFUSED_DAYS: Readonly<Record<RefusedHorizon, number>> = { quarter: 90, 'half-year': 182, year: 365 };
29
+
30
+ export interface RecapWindow {
31
+ readonly horizon: RecapHorizon;
32
+ /** Inclusive ISO date (YYYY-MM-DD) of the first day in the window. */
33
+ readonly startIso: string;
34
+ /** Inclusive ISO date of the last day — the anchor. */
35
+ readonly endIso: string;
36
+ readonly days: number;
37
+ }
38
+
39
+ const DAY_MS = 86_400_000;
40
+
41
+ function isoDay(value: string): string {
42
+ return value.slice(0, 10);
43
+ }
44
+
45
+ /**
46
+ * Does this calendar day exist?
47
+ *
48
+ * `Date.parse` ROLLS OVER an impossible day — 2026-02-31 becomes 2026-03-03 — so a NaN check alone
49
+ * lets a nonexistent date behave like a real one. It bit twice: once producing a reversed window
50
+ * (round 1) and once letting a record dated 2026-02-31 be counted inside a March window (round 10,
51
+ * cross-family QE, codex gpt-5.6-sol, 2026-08-22).
52
+ */
53
+ function isRealIsoDay(day: string): boolean {
54
+ const ms = Date.parse(`${day}T00:00:00.000Z`);
55
+ return !Number.isNaN(ms) && new Date(ms).toISOString().slice(0, 10) === day;
56
+ }
57
+
58
+ /**
59
+ * The window for a horizon, anchored on an EXPLICIT date. Never reads the clock.
60
+ * `atIso` may be a full timestamp; only its date part is used.
61
+ */
62
+ export function recapWindow(horizon: RecapHorizon, atIso: string): RecapWindow {
63
+ const end = isoDay(atIso);
64
+ const endMs = Date.parse(`${end}T00:00:00.000Z`);
65
+ if (Number.isNaN(endMs)) throw new Error(`recapWindow: not an ISO date: ${JSON.stringify(atIso)}`);
66
+ // A NaN check is not enough: `Date.parse` ROLLS OVER an impossible day, so '2026-02-31' parses to
67
+ // 2026-03-03 and produced a window whose start was AFTER its end — an internally reversed window
68
+ // that reported on nothing and said nothing about it (cross-family QE, codex gpt-5.6-sol,
69
+ // 2026-08-22, grade F finding 1). The date must survive the round trip to be that date.
70
+ if (new Date(endMs).toISOString().slice(0, 10) !== end) {
71
+ throw new Error(`recapWindow: no such date: ${JSON.stringify(atIso)}`);
72
+ }
73
+ const days = HORIZON_DAYS[horizon];
74
+ const startMs = endMs - (days - 1) * DAY_MS;
75
+ return { horizon, startIso: new Date(startMs).toISOString().slice(0, 10), endIso: end, days };
76
+ }
77
+
78
+ /** Is this instant inside the window? Compared in UTC, never as strings (ADR/AM-5). */
79
+ export function withinWindow(w: RecapWindow, isoInstant: string): boolean {
80
+ // A record dated on a day that does not exist is not a record about any day in this window. Left
81
+ // to `Date.parse` alone, 2026-02-31 rolls to 2026-03-03 and would be counted in a March window
82
+ // (round 10) — a count backed by a date nobody could have written.
83
+ if (!isRealIsoDay(isoDay(isoInstant))) return false;
84
+ const t = Date.parse(isoInstant);
85
+ if (Number.isNaN(t)) return false;
86
+ const from = Date.parse(`${w.startIso}T00:00:00.000Z`);
87
+ const to = Date.parse(`${w.endIso}T00:00:00.000Z`) + DAY_MS;
88
+ return t >= from && t < to;
89
+ }
90
+
91
+ export interface HorizonDecision {
92
+ readonly action: 'report' | 'refuse';
93
+ readonly reason: string;
94
+ }
95
+
96
+ /**
97
+ * May we report over the requested horizon at all?
98
+ *
99
+ * `spanDays` is the age of the LONGEST record we actually hold. Asking for a year over 174 days of
100
+ * data is not a thin report — it is an invented one, so it is refused and the real span is named.
101
+ */
102
+ export function decideHorizon(input: { requested: string; spanDays: number }): HorizonDecision {
103
+ const req = input.requested.trim().toLowerCase();
104
+ // An unknown span is NOT a span of zero. Coercing NaN to 0 made the refusal say "we hold 0",
105
+ // which is a claim about the records; all we know is that we could not measure them (round 14,
106
+ // codex gpt-5.6-sol, 2026-08-22). The refusal itself was already correct — only its reason lied.
107
+ const known = Number.isFinite(input.spanDays);
108
+ const span = known ? Math.max(0, Math.floor(input.spanDays)) : 0;
109
+ const held = known ? `${span} day(s)` : 'an unmeasurable amount';
110
+ if (!known) {
111
+ return { action: 'refuse', reason: `the span of the records could not be measured, so no horizon can be supported — ${JSON.stringify(input.spanDays)} is not a number of days` };
112
+ }
113
+ // `in` walks the PROTOTYPE CHAIN, so `'constructor'` and `'toString'` passed as supported
114
+ // horizons and the reason came back as `function Object() { [native code] } day(s)` — a report
115
+ // authorised for a horizon that does not exist (round 11, codex gpt-5.6-sol, 2026-08-22).
116
+ if (RECAP_HORIZONS.includes(req as RecapHorizon)) {
117
+ const need = HORIZON_DAYS[req as RecapHorizon];
118
+ if (span < need) {
119
+ return {
120
+ action: 'refuse',
121
+ reason: `a ${req} needs ${need} day(s) of records and we hold ${held} — reporting it would invent the difference`,
122
+ };
123
+ }
124
+ return { action: 'report', reason: `${req}: ${need} day(s) against ${span} day(s) of records` };
125
+ }
126
+ if ((REFUSED_HORIZONS as readonly string[]).includes(req)) {
127
+ const need = REFUSED_DAYS[req as RefusedHorizon];
128
+ // The reason is DERIVED from the span, never asserted. The first version hardcoded "there is
129
+ // exactly one complete quarter" — true of this repository on the day it was written, false for
130
+ // any other span, and printed verbatim even when the records held a single day (cross-family QE
131
+ // round 5, codex gpt-5.6-sol, 2026-08-22). A refusal that over-claims is the same defect as a
132
+ // report that over-claims.
133
+ // TWO DIFFERENT refusals, and conflating them made the message false in one of them (round 6):
134
+ // with 180 days of records a quarter is NOT short of data, so "it would invent the difference"
135
+ // claimed a difference that does not exist. The horizon is refused BY DESIGN — this tool reports
136
+ // day, week and month — and only sometimes ALSO short of data.
137
+ if (span < need) {
138
+ return {
139
+ action: 'refuse',
140
+ reason: `a ${req} needs about ${need} days of records and the longest record here spans ${span} day(s), so reporting it would invent the difference`,
141
+ };
142
+ }
143
+ // Round 7: the previous wording asserted that "the sections here begin on DIFFERENT dates" —
144
+ // true of this repository, but NOT DERIVABLE from anything this function receives. It is given
145
+ // a horizon and a span; a reason built from anything else is a claim its own inputs cannot
146
+ // support, which is the very failure this module exists to prevent. So it says only that, and
147
+ // the design rationale lives where the evidence for it lives (ADR-001, and each section's own
148
+ // data-start date in the report).
149
+ return {
150
+ action: 'refuse',
151
+ reason: `a ${req} is not a supported horizon — this reports a day, a week or a month. The records span ${span} day(s)`,
152
+ };
153
+ }
154
+ return { action: 'refuse', reason: `unknown horizon ${JSON.stringify(input.requested)} — use --day, --week or --month` };
155
+ }
156
+
157
+ export type SectionStatus = 'full' | 'partial' | 'unavailable';
158
+
159
+ export interface SectionVerdict {
160
+ readonly status: SectionStatus;
161
+ /** The date this source's records begin — computed from the source itself, never hardcoded. */
162
+ readonly dataStart: string | null;
163
+ readonly note: string;
164
+ }
165
+
166
+ /**
167
+ * Does this source cover the whole window?
168
+ *
169
+ * `dataStart: null` means the source was NOT READ — a different fact from "read, and empty".
170
+ * Collapsing the two is how a report says "nothing happened" about a week it could not see.
171
+ */
172
+ export function sectionStatus(input: { dataStart: string | null; windowStart: string; windowEnd?: string }): SectionVerdict {
173
+ if (input.dataStart === null) {
174
+ return { status: 'unavailable', dataStart: null, note: 'source not read — this section says nothing, which is not the same as zero' };
175
+ }
176
+ const start = isoDay(input.dataStart);
177
+ // Round 13: an impossible `dataStart` (2026-02-31) was accepted and reported as covering the
178
+ // whole window. A date that does not exist cannot support a coverage claim — the same rule
179
+ // already applied to the window anchor and to record membership, now applied here too.
180
+ if (!isRealIsoDay(start)) {
181
+ return { status: 'unavailable', dataStart: null, note: `the recorded start date ${JSON.stringify(input.dataStart)} is not a real date — this section cannot be trusted to cover anything` };
182
+ }
183
+ if (start <= isoDay(input.windowStart)) {
184
+ return { status: 'full', dataStart: start, note: `records cover the whole window (from ${start})` };
185
+ }
186
+ // Round 8 (codex gpt-5.6-sol, 2026-08-22): the note said the records begin "inside the window",
187
+ // which this function could not know — it was never given the window's END. Two consequences:
188
+ // the wording now claims only what the inputs support, and when the end IS supplied, records
189
+ // that begin after it are reported as covering nothing rather than as a partial view.
190
+ const end = input.windowEnd === undefined ? null : isoDay(input.windowEnd);
191
+ if (end !== null && start > end) {
192
+ return { status: 'unavailable', dataStart: start, note: `records only begin ${start}, after this window ends — this source says nothing about it` };
193
+ }
194
+ const where = end === null ? 'after this window starts' : 'inside the window';
195
+ return { status: 'partial', dataStart: start, note: `records only begin ${start}, ${where} — everything before that is unknown, not absent` };
196
+ }
197
+
198
+ export interface ForbiddenMetric {
199
+ readonly name: string;
200
+ /** The measurement that disqualifies it. A bare ban decays into a word list. */
201
+ readonly reason: string;
202
+ }
203
+
204
+ /**
205
+ * Measures of the INSTRUMENT, not of the work (ADR-003). Each will be proposed again precisely
206
+ * because each is a one-liner to compute, so each carries the measurement that refutes it.
207
+ */
208
+ export const FORBIDDEN_METRICS: readonly ForbiddenMetric[] = [
209
+ { name: 'commit count', reason: 'this project mandates a commit per logical change; 1318 commits over 66 active days measures compliance with that rule, not output (MEASURED 2026-08-22)' },
210
+ { name: 'lines changed', reason: 'generated artifacts dominate — 352 of 1318 commits are docs, and one pipeline run writes 8-10 files before a line of product code exists' },
211
+ { name: 'token spend', reason: 'self-declared an estimate, once wrong sixfold, covers 17 days, and 20 of 86 ledger rows carry no number by construction' },
212
+ { name: 'learning event volume', reason: '640 in May against 10454 in August, but the sources are post-edit and post-command hooks: the curve measures when hooks were installed' },
213
+ { name: 'inventory counts', reason: 'skills, packages and tests only ever grow, so they can only ever flatter' },
214
+ { name: 'learned lesson count', reason: 'already refuted by this project’s own dz compounding: 54% of the pool has never been read by anyone' },
215
+ ];
216
+
217
+ // ── the report ──────────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * A delivery, with the grade its report STATES.
221
+ *
222
+ * A discriminated union, not a status plus a nullable field: the pair `{gradeStatus: 'unique',
223
+ * grade: null}` used to be spellable, and it printed `1 carry a grade …: null×1` — a count of
224
+ * graded deliveries backed by no grade (cross-family QE round 4, codex gpt-5.6-sol, 2026-08-22).
225
+ * The union makes that pair a compile error, and `normaliseDelivery` catches it at runtime for
226
+ * callers who reach this from JavaScript.
227
+ */
228
+ export type Delivery =
229
+ | { readonly slug: string; readonly createdIso: string; readonly gradeStatus: 'unique'; readonly grade: string }
230
+ | { readonly slug: string; readonly createdIso: string; readonly gradeStatus: 'ambiguous' | 'none' | 'no-report'; readonly grade: null };
231
+
232
+ /** A `unique` with no usable grade is not a graded delivery; it is a report we could not read. */
233
+ function normaliseDelivery(d: Delivery): Delivery {
234
+ if (d.gradeStatus === 'unique' && (typeof d.grade !== 'string' || d.grade.trim() === '')) {
235
+ return { slug: d.slug, createdIso: d.createdIso, gradeStatus: 'none', grade: null };
236
+ }
237
+ return d;
238
+ }
239
+
240
+ export interface Publish {
241
+ readonly pkg: string;
242
+ readonly version: string;
243
+ readonly iso: string;
244
+ }
245
+
246
+ export interface GuardRun {
247
+ readonly iso: string;
248
+ readonly verdict: string;
249
+ readonly rules: readonly string[];
250
+ }
251
+
252
+ export interface ReuseFacts {
253
+ readonly dataStart: string | null;
254
+ readonly eventsInWindow: number;
255
+ readonly lessonsEverRecalled: number;
256
+ readonly lessonsTotal: number;
257
+ }
258
+
259
+ export interface SourceFacts<T> {
260
+ readonly dataStart: string | null;
261
+ readonly items: readonly T[];
262
+ }
263
+
264
+ export interface RecapFacts {
265
+ readonly window: RecapWindow;
266
+ /** The longest record we hold, in days — what `decideHorizon` judges against. */
267
+ readonly spanDays: number;
268
+ /** `null` means NOT READ. An empty `items` means read-and-empty. The type keeps them apart. */
269
+ readonly deliveries: SourceFacts<Delivery> | null;
270
+ readonly publishes: SourceFacts<Publish> | null;
271
+ readonly guard: SourceFacts<GuardRun> | null;
272
+ readonly reuse: ReuseFacts | null;
273
+ /** Feature dirs on disk that git has never seen — the report is blind to them, and says so. */
274
+ readonly uncommittedSlugs: readonly string[];
275
+ }
276
+
277
+ export interface RecapSection {
278
+ readonly id: 'deliveries' | 'publishes' | 'discipline' | 'reuse';
279
+ readonly title: string;
280
+ readonly verdict: SectionVerdict;
281
+ readonly lines: readonly string[];
282
+ }
283
+
284
+ export interface RecapReport {
285
+ readonly window: RecapWindow;
286
+ readonly spanDays: number;
287
+ readonly sections: readonly RecapSection[];
288
+ readonly caveats: readonly string[];
289
+ }
290
+
291
+ /**
292
+ * The window narrowed to what this source's records actually cover.
293
+ *
294
+ * Round 3 of the cross-family review (codex gpt-5.6-sol, 2026-08-22): labelling the covered range
295
+ * was not enough while the COUNT was still taken over the whole window. An item dated inside the
296
+ * window but before the records begin was counted, under a label promising a later range — an
297
+ * unsupported number wearing an honest caption. Filtering on the narrowed window makes the count
298
+ * and the caption agree BY CONSTRUCTION, which no assertion can undo.
299
+ */
300
+ function coveredWindow(v: SectionVerdict, w: RecapWindow): RecapWindow {
301
+ if (v.status !== 'partial' || v.dataStart === null) return w;
302
+ const startIso = v.dataStart > w.startIso ? v.dataStart : w.startIso;
303
+ const days = Math.round((Date.parse(`${w.endIso}T00:00:00Z`) - Date.parse(`${startIso}T00:00:00Z`)) / DAY_MS) + 1;
304
+ return { horizon: w.horizon, startIso, endIso: w.endIso, days: Math.max(0, days) };
305
+ }
306
+
307
+ const UNAVAILABLE_LINES: readonly string[] = ['not read — this section cannot say anything about this window'];
308
+
309
+ function emptyOrUnavailable(v: SectionVerdict, emptyLine: string): string[] {
310
+ return v.status === 'unavailable' ? [...UNAVAILABLE_LINES] : [emptyLine];
311
+ }
312
+
313
+ /**
314
+ * The safety property, enforced at ONE place rather than trusted at four.
315
+ *
316
+ * A source with no `dataStart` was NOT READ, and a section that was not read must not present
317
+ * counts — whatever items happen to sit alongside. The first version branched on `items.length`
318
+ * first, so `{dataStart: null, items: [...]}` printed "1 feature directory created" under an
319
+ * `unavailable` verdict: unsupported numbers, which is exactly what this feature exists to prevent
320
+ * (cross-family QE, codex gpt-5.6-sol, 2026-08-22, grade F finding 2).
321
+ */
322
+ function sectionLines(v: SectionVerdict, window: RecapWindow, compute: (scope: string) => string[]): string[] {
323
+ if (v.status === 'unavailable') return [...UNAVAILABLE_LINES];
324
+ if (v.status === 'partial') {
325
+ // Round 2 of the same review found the identical honesty failure one level up: a `partial`
326
+ // section printed WHOLE-WINDOW counts, and a zero read as "nothing happened all week" when the
327
+ // first days of the week simply have no records. A count here is supported only over the range
328
+ // the records actually cover, so it is labelled with that range and never with the window.
329
+ const covered = `${v.dataStart as string} … ${window.endIso}`;
330
+ return [
331
+ `these numbers cover ONLY ${covered} — ${window.startIso} to the day before ${v.dataStart as string} has no records, so it is unknown, not zero`,
332
+ ...compute(`the covered range (${covered})`),
333
+ ];
334
+ }
335
+ return compute('this window');
336
+ }
337
+
338
+ export function buildRecap(facts: RecapFacts): RecapReport {
339
+ const ws = facts.window.startIso;
340
+ const we = facts.window.endIso;
341
+ const sections: RecapSection[] = [];
342
+
343
+ // 1. Deliveries
344
+ {
345
+ const v = sectionStatus({ dataStart: facts.deliveries?.dataStart ?? null, windowStart: ws, windowEnd: we });
346
+ const covered = coveredWindow(v, facts.window);
347
+ const items = (facts.deliveries?.items ?? []).map(normaliseDelivery).filter((d) => withinWindow(covered, d.createdIso));
348
+ const graded = items.filter((d): d is Extract<Delivery, { gradeStatus: 'unique' }> => d.gradeStatus === 'unique');
349
+ const ambiguous = items.filter((d) => d.gradeStatus === 'ambiguous');
350
+ const ungraded = items.filter((d) => d.gradeStatus === 'none' || d.gradeStatus === 'no-report');
351
+ const lines = sectionLines(v, facts.window, (scope) => items.length === 0
352
+ ? emptyOrUnavailable(v, `no feature directories were created in ${scope}`)
353
+ : [
354
+ `${items.length} feature director${items.length === 1 ? 'y' : 'ies'} created in ${scope}`,
355
+ `${graded.length} carry a grade an independent review stated unambiguously${graded.length > 0 ? `: ${tally(graded.map((d) => d.grade))}` : ''}`,
356
+ `${ambiguous.length} have a report that states MORE THAN ONE grade — reported as ambiguous, never guessed`,
357
+ `${ungraded.length} have no letter grade in their report, or no report at all`,
358
+ 'cadence is not value: this counts deliveries, not what they were worth',
359
+ ]);
360
+ sections.push({ id: 'deliveries', title: 'Deliveries', verdict: v, lines });
361
+ }
362
+
363
+ // 2. Publishes
364
+ {
365
+ const v = sectionStatus({ dataStart: facts.publishes?.dataStart ?? null, windowStart: ws, windowEnd: we });
366
+ const covered = coveredWindow(v, facts.window);
367
+ const items = (facts.publishes?.items ?? []).filter((p) => withinWindow(covered, p.iso));
368
+ const pkgs = new Set(items.map((p) => p.pkg));
369
+ const lines = sectionLines(v, facts.window, (scope) => items.length === 0
370
+ ? emptyOrUnavailable(v, `no versions were accepted by the registry in ${scope}`)
371
+ : [
372
+ `${items.length} version(s) accepted by the registry across ${pkgs.size} package(s) in ${scope}`,
373
+ 'timestamps are the registry’s own — this is the one record here nobody local can backdate',
374
+ ]);
375
+ sections.push({ id: 'publishes', title: 'Publishes', verdict: v, lines });
376
+ }
377
+
378
+ // 3. Discipline — DESCRIBES, never compares (ADR-004)
379
+ {
380
+ const v = sectionStatus({ dataStart: facts.guard?.dataStart ?? null, windowStart: ws, windowEnd: we });
381
+ const covered = coveredWindow(v, facts.window);
382
+ const runs = (facts.guard?.items ?? []).filter((g) => withinWindow(covered, g.iso));
383
+ const byVerdict = new Map<string, number>();
384
+ const byRule = new Map<string, number>();
385
+ for (const r of runs) {
386
+ byVerdict.set(r.verdict, (byVerdict.get(r.verdict) ?? 0) + 1);
387
+ for (const rule of r.rules) byRule.set(rule, (byRule.get(rule) ?? 0) + 1);
388
+ }
389
+ const lines = sectionLines(v, facts.window, (scope) => runs.length === 0
390
+ ? emptyOrUnavailable(v, `no gate runs were recorded in ${scope}`)
391
+ : [
392
+ `${runs.length} gate run(s) in ${scope}: ${[...byVerdict.entries()].map(([k, n]) => `${k} ${n}`).join(', ')}`,
393
+ byRule.size === 0
394
+ ? `no rule was violated in ${scope}`
395
+ : `violations by rule: ${[...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(', ')}`,
396
+ ]);
397
+ // The caveat is mandatory for every section that HAS data — but not for one that has none.
398
+ // Round 9 (codex gpt-5.6-sol, 2026-08-22): pushed unconditionally, an unread section claimed "a
399
+ // missing rule was never violated or did not yet exist", when with no log a missing rule may
400
+ // simply have fired unseen; and "this section describes the window" was false of a section
401
+ // describing nothing. A caveat that over-claims is still an over-claim.
402
+ if (v.status !== 'unavailable') {
403
+ // The caveat must be exactly as strong as the coverage. On a PARTIAL section a missing rule
404
+ // has a THIRD possible history — violated in the part these records do not reach — and the
405
+ // two-option wording denied it (round 12, codex gpt-5.6-sol, 2026-08-22).
406
+ lines.push(v.status === 'partial'
407
+ ? 'a rule missing from this list was never violated in the covered range, did not yet exist, or was violated before these records begin — the log records a rule only when it fires, so it cannot tell those apart'
408
+ : 'a rule missing from this list was either never violated or did not yet exist — the log records a rule only when it fires, so it cannot tell those apart');
409
+ lines.push('this section describes the records it has; it deliberately computes no comparison between periods');
410
+ }
411
+ sections.push({ id: 'discipline', title: 'Discipline', verdict: v, lines });
412
+ }
413
+
414
+ // 4. Knowledge reuse
415
+ {
416
+ const v = sectionStatus({ dataStart: facts.reuse?.dataStart ?? null, windowStart: ws, windowEnd: we });
417
+ const r = facts.reuse;
418
+ // `eventsInWindow` arrives ALREADY aggregated over the requested window, so unlike every other
419
+ // section it cannot be re-filtered to the covered range here. A partial reuse section therefore
420
+ // withholds the event count rather than captioning it with a range it does not match.
421
+ const lines = sectionLines(v, facts.window, (scope) => r === null || r.lessonsTotal === 0
422
+ ? emptyOrUnavailable(v, `no lessons are stored, so there is nothing to reuse in ${scope}`)
423
+ : [
424
+ v.status === 'partial'
425
+ ? 'the recall-event count is withheld: it was aggregated over the whole window, which these records do not cover'
426
+ : `${r.eventsInWindow} recall event(s) in ${scope}`,
427
+ `${r.lessonsEverRecalled} of ${r.lessonsTotal} stored lessons have ever been read (${Math.round((r.lessonsEverRecalled / r.lessonsTotal) * 100)}%)`,
428
+ 'this is a ratio with a hostile denominator: storing more unread lessons makes it worse, not better',
429
+ ]);
430
+ sections.push({ id: 'reuse', title: 'Knowledge reuse', verdict: v, lines });
431
+ }
432
+
433
+ const caveats: string[] = [];
434
+ if (facts.uncommittedSlugs.length > 0) {
435
+ caveats.push(`${facts.uncommittedSlugs.length} feature director${facts.uncommittedSlugs.length === 1 ? 'y is' : 'ies are'} not committed yet and therefore invisible to this report: ${facts.uncommittedSlugs.join(', ')}`);
436
+ }
437
+ caveats.push('none of the following is computed here, and each carries the measurement that disqualifies it: ' + FORBIDDEN_METRICS.map((m) => m.name).join(', '));
438
+
439
+ return { window: facts.window, spanDays: facts.spanDays, sections, caveats };
440
+ }
441
+
442
+ function tally(values: readonly string[]): string {
443
+ const m = new Map<string, number>();
444
+ for (const v of values) m.set(v, (m.get(v) ?? 0) + 1);
445
+ return [...m.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([k, n]) => `${k}×${n}`).join(' ');
446
+ }
447
+
448
+ /** The human rendering. `--json` prints the SAME `RecapReport` — one structure, two spellings (FR-6). */
449
+ export function renderRecap(report: RecapReport): string[] {
450
+ const out: string[] = [];
451
+ out.push(`dz recap — ${report.window.horizon}: ${report.window.startIso} … ${report.window.endIso} (${report.window.days} day(s))`);
452
+ out.push(`records held: ${report.spanDays} day(s)`);
453
+ for (const s of report.sections) {
454
+ out.push('');
455
+ out.push(`${s.title} [${s.verdict.status}]${s.verdict.dataStart !== null ? ` · records from ${s.verdict.dataStart}` : ''}`);
456
+ out.push(` ${s.verdict.note}`);
457
+ for (const l of s.lines) out.push(` · ${l}`);
458
+ }
459
+ out.push('');
460
+ for (const c of report.caveats) out.push(`! ${c}`);
461
+ return out;
462
+ }
package/src/score.ts CHANGED
@@ -93,11 +93,61 @@ function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEG
93
93
 
94
94
  // Word-bounded on BOTH sides: "upgrade B-tree" fabricated a B- (Codex QE #1). The lookahead also
95
95
  // rejects "Grade B-tree" (letter after the dash) while keeping the real "Grade: A−" formats.
96
- const GRADE_RE = /(?<![A-Za-z])[Gg]rade[d]?:?\s*\*{0,2}\s*([A-F][+−-]?)(?![A-Za-z-])/;
96
+ //
97
+ // CASE: the whole word is case-insensitive, not just its first letter. The previous `[Gg]rade`
98
+ // could never match an all-caps `GRADE A` — MEASURED 2026-08-22: 15 reports spell it that way, and
99
+ // for several of them it is the ONLY grade in the file, so the parser returned `null` about a
100
+ // report that plainly states its verdict (ADR-002, features/dz-recap).
101
+ // The trailing `(?![A-Za-z])` is NOT decoration. Making the word case-insensitive let `GRADED by
102
+ // an independent reviewer` match: the engine took `GRADE`, skipped the optional `d`, and read the
103
+ // word's own final `D` as the grade. The old lowercase-only pattern could never reach that state,
104
+ // so widening the alphabet shifted the threshold — caught by the regression half of the test,
105
+ // which is exactly why that half is mandatory (recalled lesson, Step 0).
106
+ // NOT global. `evidenceLine` calls `re.test(line)` in a loop, and a /g regex carries `lastIndex`
107
+ // between calls — it would skip matches on every other line. `readQeGrade` makes its own global
108
+ // copy instead. (Caught by the existing evidence-locator test when /g was added here.)
109
+ const GRADE_RE = /(?<![A-Za-z])[Gg][Rr][Aa][Dd][Ee][Dd]?(?![A-Za-z]):?\s*\*{0,2}\s*([A-F][+\u2212-]?)(?![A-Za-z-])/;
110
+
111
+ /** U+2212 and the ASCII hyphen spell the same grade; a tally must not count them twice. */
112
+ function normaliseGradeSign(grade: string): string {
113
+ return grade.replace('\u2212', '-');
114
+ }
115
+
116
+ export type GradeReadStatus = 'unique' | 'ambiguous' | 'none';
117
+
118
+ export interface GradeReading {
119
+ readonly status: GradeReadStatus;
120
+ /** The grade, ONLY when the report names one unambiguously. */
121
+ readonly grade: string | null;
122
+ /** Every distinct grade found, normalised — what makes an `ambiguous` verdict inspectable. */
123
+ readonly found: readonly string[];
124
+ }
125
+
126
+ /**
127
+ * Read the review grade a report states — and refuse to guess when it states more than one.
128
+ *
129
+ * The obvious rules are both WRONG, and both were measured before this was written (ADR-002):
130
+ * FIRST match returns the round-1 grade of a report that was later fixed; LAST match returns a
131
+ * section heading naming the pre-fix grade, or a sentence quoting a grade in prose. Across 154
132
+ * real reports the two disagree in 14 files, and in `crossrt-2-codex-hooks` NEITHER is right —
133
+ * its true verdict is an all-caps `GRADE A` the old regex could not see at all.
134
+ *
135
+ * So: a grade is reported only when every occurrence agrees. Otherwise the caller is told the
136
+ * report is ambiguous, which is a fact about the report, not a missing number.
137
+ */
138
+ export function readQeGrade(qeText: string): GradeReading {
139
+ const found: string[] = [];
140
+ for (const m of qeText.matchAll(new RegExp(GRADE_RE.source, 'g'))) {
141
+ const g = normaliseGradeSign(m[1] as string);
142
+ if (!found.includes(g)) found.push(g);
143
+ }
144
+ if (found.length === 0) return { status: 'none', grade: null, found: [] };
145
+ if (found.length === 1) return { status: 'unique', grade: found[0] as string, found };
146
+ return { status: 'ambiguous', grade: null, found };
147
+ }
97
148
 
98
149
  export function extractQeGrade(qeText: string): string | null {
99
- const m = GRADE_RE.exec(qeText);
100
- return m?.[1] ?? null;
150
+ return readQeGrade(qeText).grade;
101
151
  }
102
152
 
103
153
  export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {