@dzhechkov/harness-core 0.8.10 → 0.8.20

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 (251) hide show
  1. package/.dz-manifest.json +490 -170
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/codex-invoke.d.ts +73 -0
  46. package/dist/codex-invoke.d.ts.map +1 -0
  47. package/dist/codex-invoke.js +80 -0
  48. package/dist/codex-invoke.js.map +1 -0
  49. package/dist/course-staleness.d.ts +19 -0
  50. package/dist/course-staleness.d.ts.map +1 -0
  51. package/dist/course-staleness.js +95 -0
  52. package/dist/course-staleness.js.map +1 -0
  53. package/dist/destructive-guard-hook.d.ts +40 -0
  54. package/dist/destructive-guard-hook.d.ts.map +1 -0
  55. package/dist/destructive-guard-hook.js +109 -0
  56. package/dist/destructive-guard-hook.js.map +1 -0
  57. package/dist/destructive-guard.d.ts +27 -0
  58. package/dist/destructive-guard.d.ts.map +1 -0
  59. package/dist/destructive-guard.js +2808 -0
  60. package/dist/destructive-guard.js.map +1 -0
  61. package/dist/discrimination-gate.d.ts +88 -3
  62. package/dist/discrimination-gate.d.ts.map +1 -1
  63. package/dist/discrimination-gate.js +173 -16
  64. package/dist/discrimination-gate.js.map +1 -1
  65. package/dist/event-chain.d.ts +30 -0
  66. package/dist/event-chain.d.ts.map +1 -1
  67. package/dist/event-chain.js +24 -0
  68. package/dist/event-chain.js.map +1 -1
  69. package/dist/feature-adr-routing.d.ts +95 -1
  70. package/dist/feature-adr-routing.d.ts.map +1 -1
  71. package/dist/feature-adr-routing.js +193 -27
  72. package/dist/feature-adr-routing.js.map +1 -1
  73. package/dist/guard.d.ts +98 -0
  74. package/dist/guard.d.ts.map +1 -1
  75. package/dist/guard.js +308 -0
  76. package/dist/guard.js.map +1 -1
  77. package/dist/harness-core-location.d.ts +18 -0
  78. package/dist/harness-core-location.d.ts.map +1 -0
  79. package/dist/harness-core-location.js +42 -0
  80. package/dist/harness-core-location.js.map +1 -0
  81. package/dist/index.d.ts +32 -11
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +32 -7
  84. package/dist/index.js.map +1 -1
  85. package/dist/lead-shift-gate.d.ts +95 -0
  86. package/dist/lead-shift-gate.d.ts.map +1 -0
  87. package/dist/lead-shift-gate.js +100 -0
  88. package/dist/lead-shift-gate.js.map +1 -0
  89. package/dist/ledger-backfill.d.ts +11 -1
  90. package/dist/ledger-backfill.d.ts.map +1 -1
  91. package/dist/ledger-backfill.js +19 -0
  92. package/dist/ledger-backfill.js.map +1 -1
  93. package/dist/loop-blobs.generated.d.ts +1 -1
  94. package/dist/loop-blobs.generated.d.ts.map +1 -1
  95. package/dist/loop-blobs.generated.js +12 -3
  96. package/dist/loop-blobs.generated.js.map +1 -1
  97. package/dist/loop-lint.d.ts.map +1 -1
  98. package/dist/loop-lint.js +56 -7
  99. package/dist/loop-lint.js.map +1 -1
  100. package/dist/loop-plan-graph.d.ts +1 -3
  101. package/dist/loop-plan-graph.d.ts.map +1 -1
  102. package/dist/loop-plan-graph.js +70 -1
  103. package/dist/loop-plan-graph.js.map +1 -1
  104. package/dist/loop-trace.d.ts.map +1 -1
  105. package/dist/loop-trace.js +16 -2
  106. package/dist/loop-trace.js.map +1 -1
  107. package/dist/managed-hooks.d.ts +5 -6
  108. package/dist/managed-hooks.d.ts.map +1 -1
  109. package/dist/managed-hooks.js +2 -2
  110. package/dist/managed-hooks.js.map +1 -1
  111. package/dist/model-recommender.d.ts.map +1 -1
  112. package/dist/model-recommender.js +14 -3
  113. package/dist/model-recommender.js.map +1 -1
  114. package/dist/mutation-gate.d.ts +39 -36
  115. package/dist/mutation-gate.d.ts.map +1 -1
  116. package/dist/mutation-gate.js +111 -5
  117. package/dist/mutation-gate.js.map +1 -1
  118. package/dist/operations.d.ts.map +1 -1
  119. package/dist/operations.js +90 -5
  120. package/dist/operations.js.map +1 -1
  121. package/dist/patterns.d.ts +23 -0
  122. package/dist/patterns.d.ts.map +1 -1
  123. package/dist/patterns.js +10 -0
  124. package/dist/patterns.js.map +1 -1
  125. package/dist/plugin.d.ts.map +1 -1
  126. package/dist/plugin.js +27 -5
  127. package/dist/plugin.js.map +1 -1
  128. package/dist/publish.d.ts +11 -0
  129. package/dist/publish.d.ts.map +1 -1
  130. package/dist/publish.js +16 -2
  131. package/dist/publish.js.map +1 -1
  132. package/dist/recommend.d.ts +4 -5
  133. package/dist/recommend.d.ts.map +1 -1
  134. package/dist/recommend.js +110 -45
  135. package/dist/recommend.js.map +1 -1
  136. package/dist/registry.d.ts +32 -1
  137. package/dist/registry.d.ts.map +1 -1
  138. package/dist/registry.js +168 -11
  139. package/dist/registry.js.map +1 -1
  140. package/dist/run-records.d.ts +3 -0
  141. package/dist/run-records.d.ts.map +1 -1
  142. package/dist/run-records.js +18 -0
  143. package/dist/run-records.js.map +1 -1
  144. package/dist/score.d.ts +95 -0
  145. package/dist/score.d.ts.map +1 -1
  146. package/dist/score.js +289 -6
  147. package/dist/score.js.map +1 -1
  148. package/dist/session-retro.d.ts +121 -2
  149. package/dist/session-retro.d.ts.map +1 -1
  150. package/dist/session-retro.js +454 -11
  151. package/dist/session-retro.js.map +1 -1
  152. package/dist/setup.d.ts +28 -0
  153. package/dist/setup.d.ts.map +1 -1
  154. package/dist/setup.js +223 -6
  155. package/dist/setup.js.map +1 -1
  156. package/dist/sign.d.ts.map +1 -1
  157. package/dist/sign.js +18 -1
  158. package/dist/sign.js.map +1 -1
  159. package/dist/skill-selection.d.ts +72 -0
  160. package/dist/skill-selection.d.ts.map +1 -0
  161. package/dist/skill-selection.js +76 -0
  162. package/dist/skill-selection.js.map +1 -0
  163. package/dist/skills-verify.d.ts +34 -1
  164. package/dist/skills-verify.d.ts.map +1 -1
  165. package/dist/skills-verify.js +82 -0
  166. package/dist/skills-verify.js.map +1 -1
  167. package/dist/stage-line.d.ts +68 -0
  168. package/dist/stage-line.d.ts.map +1 -0
  169. package/dist/stage-line.js +129 -0
  170. package/dist/stage-line.js.map +1 -0
  171. package/dist/statusline.d.ts +99 -0
  172. package/dist/statusline.d.ts.map +1 -1
  173. package/dist/statusline.js +310 -44
  174. package/dist/statusline.js.map +1 -1
  175. package/dist/stem.d.ts +12 -0
  176. package/dist/stem.d.ts.map +1 -0
  177. package/dist/stem.js +89 -0
  178. package/dist/stem.js.map +1 -0
  179. package/dist/store-counts.d.ts +26 -0
  180. package/dist/store-counts.d.ts.map +1 -0
  181. package/dist/store-counts.js +125 -0
  182. package/dist/store-counts.js.map +1 -0
  183. package/dist/store-guard.d.ts +106 -0
  184. package/dist/store-guard.d.ts.map +1 -0
  185. package/dist/store-guard.js +294 -0
  186. package/dist/store-guard.js.map +1 -0
  187. package/dist/swarm-brief.d.ts +95 -0
  188. package/dist/swarm-brief.d.ts.map +1 -0
  189. package/dist/swarm-brief.js +660 -0
  190. package/dist/swarm-brief.js.map +1 -0
  191. package/dist/telemetry-vocabulary.d.ts +7 -0
  192. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  193. package/dist/telemetry-vocabulary.js +29 -0
  194. package/dist/telemetry-vocabulary.js.map +1 -1
  195. package/dist/trace-bundle.d.ts +8 -0
  196. package/dist/trace-bundle.d.ts.map +1 -1
  197. package/dist/trace-bundle.js +11 -0
  198. package/dist/trace-bundle.js.map +1 -1
  199. package/package.json +10 -9
  200. package/sbom.json +1033 -233
  201. package/src/agents-policy.ts +46 -2
  202. package/src/amendment-trace.ts +441 -19
  203. package/src/backlog-public.ts +503 -0
  204. package/src/backlog-transitions.ts +77 -0
  205. package/src/backlog.ts +12 -0
  206. package/src/claim-check.ts +25 -2
  207. package/src/claude-hooks-assets.ts +227 -0
  208. package/src/clean-room-smoke.ts +195 -0
  209. package/src/cmd-usage.ts +29 -5
  210. package/src/codex-hooks-assets.ts +140 -30
  211. package/src/codex-hooks.ts +21 -1
  212. package/src/codex-invoke.ts +138 -0
  213. package/src/course-staleness.ts +125 -0
  214. package/src/destructive-guard-hook.ts +151 -0
  215. package/src/destructive-guard.ts +3027 -0
  216. package/src/discrimination-gate.ts +262 -19
  217. package/src/event-chain.ts +41 -0
  218. package/src/feature-adr-routing.ts +220 -22
  219. package/src/guard.ts +358 -0
  220. package/src/harness-core-location.ts +44 -0
  221. package/src/index.ts +121 -7
  222. package/src/lead-shift-gate.ts +145 -0
  223. package/src/ledger-backfill.ts +20 -1
  224. package/src/loop-blobs.generated.ts +12 -3
  225. package/src/loop-lint.ts +52 -7
  226. package/src/loop-plan-graph.ts +66 -1
  227. package/src/loop-trace.ts +13 -1
  228. package/src/managed-hooks.ts +5 -6
  229. package/src/model-recommender.ts +14 -3
  230. package/src/mutation-gate.ts +165 -5
  231. package/src/operations.ts +83 -5
  232. package/src/patterns.ts +33 -0
  233. package/src/plugin.ts +27 -5
  234. package/src/publish.ts +27 -2
  235. package/src/recommend.ts +116 -46
  236. package/src/registry.ts +147 -13
  237. package/src/run-records.ts +23 -0
  238. package/src/score.ts +377 -7
  239. package/src/session-retro.ts +466 -11
  240. package/src/setup.ts +246 -9
  241. package/src/sign.ts +18 -1
  242. package/src/skill-selection.ts +111 -0
  243. package/src/skills-verify.ts +99 -1
  244. package/src/stage-line.ts +151 -0
  245. package/src/statusline.ts +396 -47
  246. package/src/stem.ts +87 -0
  247. package/src/store-counts.ts +154 -0
  248. package/src/store-guard.ts +388 -0
  249. package/src/swarm-brief.ts +661 -0
  250. package/src/telemetry-vocabulary.ts +36 -0
  251. package/src/trace-bundle.ts +10 -0
package/src/score.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  * PURE: the CLI reads the artifact files; this module only classifies.
21
21
  */
22
22
 
23
+ import { amendmentIdsIn } from './amendment-trace.js';
24
+
23
25
  export type DisciplineVerdict = 'pass' | 'partial' | 'absent';
24
26
 
25
27
  export interface DisciplineScore {
@@ -35,6 +37,14 @@ export interface RunScorecard {
35
37
  readonly disciplines: readonly DisciplineScore[];
36
38
  /** Extracted cross-model grade, when one exists (e.g. "A−", "C"). */
37
39
  readonly qeGrade: string | null;
40
+ /**
41
+ * F30а-3, ADDITIVE: present when the run's artifacts carry a structured mutation table. Optional
42
+ * on purpose — every scorecard written before this field existed stays a valid RunScorecard, and
43
+ * `scoreAggregateRowFrom` keeps accepting rows without it. A run with no table reports `absent`
44
+ * rather than omitting the field, so "the gate never ran" and "an older scorer wrote this row"
45
+ * stay distinguishable.
46
+ */
47
+ readonly mutationEvidence?: MutationEvidence;
38
48
  readonly passed: number;
39
49
  readonly total: number;
40
50
  readonly summary: string;
@@ -161,6 +171,119 @@ function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEG
161
171
  return null;
162
172
  }
163
173
 
174
+ /**
175
+ * F30а-3 — structured mutation evidence.
176
+ *
177
+ * `discrimination` has always scored PROSE: a sentence the author writes about their own work.
178
+ * "None of the mutants survived" scores identically whether three mutations ran or zero did, which
179
+ * makes the strongest discipline in the pipeline rest on the weakest kind of evidence. The mutation
180
+ * gate already emits a five-valued verdict per registry entry, and only `PROVEN` is proof — so a
181
+ * table carrying those verdicts can be COUNTED instead of believed.
182
+ *
183
+ * The design is defined by what it REFUSES. A table that looks like evidence and proves nothing is
184
+ * worse than no table, because it buys the appearance of rigour: the empty one, the header-only one,
185
+ * the one whose every row is INCONCLUSIVE. Each of those returns a non-proving status and is NAMED
186
+ * in the scorecard evidence, never silently treated as corroboration.
187
+ */
188
+ export type MutationEvidenceStatus =
189
+ | 'proven' // ≥1 row carries the gate's PROVEN verdict — the only status that is evidence
190
+ | 'present-unproven' // a real table, but zero PROVEN rows (includes the header-only table)
191
+ | 'malformed' // a table whose verdict column holds no recognised token — unreadable, not proof
192
+ | 'absent'; // no mutation table in the text at all
193
+
194
+ export interface MutationEvidence {
195
+ readonly status: MutationEvidenceStatus;
196
+ /** Rows whose verdict is exactly PROVEN. */
197
+ readonly proven: number;
198
+ /** Data rows found under the header (0 for the header-only table). */
199
+ readonly rows: number;
200
+ /** The line the verdict rests on — evidence must show its work. */
201
+ readonly evidence: string;
202
+ }
203
+
204
+ /**
205
+ * The gate's own verdict vocabulary. Anything outside it is UNRECOGNISED, which is why a table of
206
+ * free-text opinions ("looked fine", "ok") is malformed rather than quietly scored as unproven:
207
+ * the difference between "the gate ran and found nothing" and "nobody ran the gate" is the whole
208
+ * point of the field.
209
+ */
210
+ const MUTATION_VERDICTS = new Set([
211
+ 'PROVEN',
212
+ 'MUTATION_UNPARSEABLE',
213
+ 'MUTATION_LOAD_FATAL',
214
+ 'OVER_FAILING',
215
+ 'INCONCLUSIVE',
216
+ 'SURVIVED',
217
+ 'NOT_PROVEN',
218
+ ]);
219
+
220
+ const splitRow = (line: string): string[] =>
221
+ line
222
+ .trim()
223
+ .replace(/^\|/, '')
224
+ .replace(/\|$/, '')
225
+ .split('|')
226
+ .map((cell) => cell.trim());
227
+
228
+ const isSeparatorRow = (line: string): boolean => /^\|[\s:|-]+\|?$/.test(line.trim());
229
+
230
+ export function readMutationEvidence(text: string): MutationEvidence {
231
+ const lines = String(text).split('\n');
232
+ for (let i = 0; i < lines.length; i++) {
233
+ const line = lines[i];
234
+ if (line === undefined || !line.trim().startsWith('|')) continue;
235
+ const header = splitRow(line);
236
+ // A mutation table is identified by its COLUMNS, not by a heading above it: headings drift
237
+ // across reports and translations, column names are what the parser actually reads.
238
+ const verdictCol = header.findIndex((c) => /verdict/i.test(c));
239
+ if (verdictCol < 0 || !header.some((c) => /mutation|mutant/i.test(c))) continue;
240
+
241
+ let rows = 0;
242
+ let proven = 0;
243
+ let recognised = 0;
244
+ for (let j = i + 1; j < lines.length; j++) {
245
+ const row = lines[j];
246
+ if (row === undefined || !row.trim().startsWith('|')) break;
247
+ if (isSeparatorRow(row)) continue;
248
+ const cells = splitRow(row);
249
+ rows++;
250
+ const cell = (cells[verdictCol] ?? '').toUpperCase();
251
+ // Word-bounded: NOT_PROVEN contains PROVEN as a substring and must never count as one.
252
+ const token = (cell.match(/\b[A-Z_]{4,}\b/) ?? [])[0] ?? '';
253
+ if (!MUTATION_VERDICTS.has(token)) continue;
254
+ recognised++;
255
+ if (token === 'PROVEN') proven++;
256
+ }
257
+
258
+ if (proven > 0) {
259
+ return {
260
+ status: 'proven',
261
+ proven,
262
+ rows,
263
+ evidence: `mutation table: ${proven} of ${rows} row(s) PROVEN`,
264
+ };
265
+ }
266
+ if (rows > 0 && recognised === 0) {
267
+ return {
268
+ status: 'malformed',
269
+ proven: 0,
270
+ rows,
271
+ evidence: `mutation table: ${rows} row(s), no recognised gate verdict in the Verdict column — unreadable, not proof`,
272
+ };
273
+ }
274
+ return {
275
+ status: 'present-unproven',
276
+ proven: 0,
277
+ rows,
278
+ evidence:
279
+ rows === 0
280
+ ? 'mutation table present with no data rows — proved nothing'
281
+ : `mutation table: 0 of ${rows} row(s) PROVEN — proved nothing`,
282
+ };
283
+ }
284
+ return { status: 'absent', proven: 0, rows: 0, evidence: 'no mutation table' };
285
+ }
286
+
164
287
  // Word-bounded on BOTH sides: "upgrade B-tree" fabricated a B- (Codex QE #1). The lookahead also
165
288
  // rejects "Grade B-tree" (letter after the dash) while keeping the real "Grade: A−" formats.
166
289
  //
@@ -305,14 +428,26 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
305
428
  // MUTANTS — "None of the mutants survived", "neither mutant escaped" — which is the proof, not
306
429
  // its denial. The quantifiers would discard exactly the strongest lines this discipline exists
307
430
  // to find. "No discrimination proof was performed" is still caught by the narrow list.
431
+ // F30а-3 makes this ADDITIVE: a structured mutation table is counted FIRST (it can be
432
+ // verified, prose can only be believed), and the prose path below is left exactly as it was —
433
+ // no run that scored `pass` yesterday loses it today. What the table changes is the OTHER
434
+ // direction: a table that proves nothing is named in the evidence instead of sitting silently
435
+ // beside a winning sentence and reading as corroboration.
436
+ const mutationEvidence = readMutationEvidence(allText);
308
437
  const discr =
309
438
  evidenceLinePositive(allText, /discrimination|§42/i) ??
310
439
  evidenceLinePositive(allText, /mutation[s]?\s.*(prov|kill)|mutant[s]?\s.*(kill|red)|RED on the old|goes? RED|failed as expected/i);
440
+ const discrPasses = mutationEvidence.status === 'proven' || discr !== null;
311
441
  add(
312
442
  'discrimination',
313
443
  'the property test is proven able to fail',
314
- discr !== null ? 'pass' : 'absent',
315
- discr ?? 'no discrimination/§42/mutation-proof evidence in any artifact',
444
+ discrPasses ? 'pass' : 'absent',
445
+ mutationEvidence.status === 'proven'
446
+ ? mutationEvidence.evidence
447
+ : (discr ??
448
+ (mutationEvidence.status === 'absent'
449
+ ? 'no discrimination/§42/mutation-proof evidence in any artifact'
450
+ : mutationEvidence.evidence)),
316
451
  );
317
452
 
318
453
  // 3. Cross-model QE — an independent family reviewed it, and a grade exists.
@@ -391,11 +526,21 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
391
526
  // 7. Amendment confirmation — only when amendments EXIST; their absence is not a failure.
392
527
  // Born in the PLAN/ideation: a stray "AM-7" in QE prose (e.g. another feature's test name)
393
528
  // must not conjure the discipline (caught by the very first dogfood run).
394
- const plannedAm = [...new Set(planText.match(/AM-\d+/g) ?? [])].sort();
529
+ // ONE reader of the `AM-*` token, shared with the row grammar and pinned against K2's by
530
+ // test/amendment-grammar-agreement.test.ts. It used to be a THIRD private contract here —
531
+ // `/AM-\d+/g`, which does not match `AM-CP-1` at all (MEASURED 2026-09-06:
532
+ // `'AM-CP-1'.match(/AM-\d+/g)` → `null`), so a plan whose amendments were all challenge-panel
533
+ // rows scored `plannedAm = []` and this whole discipline was skipped silently — no `absent`, no
534
+ // `partial`, just a smaller denominator. A check that silently checks nothing is the class the
535
+ // amendment gate exists to remove, and it had reappeared in the scorer.
536
+ const plannedAm = amendmentIdsIn(planText).sort();
395
537
  if (plannedAm.length > 0) {
396
- // One stray AM-9 in QE must not satisfy AM-1..AM-2 (Codex QE #3): coverage is id-by-id.
397
- const covered = plannedAm.filter((id) => qeText.includes(id));
398
- const missing = plannedAm.filter((id) => !qeText.includes(id));
538
+ // One stray AM-9 in QE must not satisfy AM-1..AM-2 (Codex QE #3): coverage is id-by-id — and
539
+ // `includes` was not id-by-id: a planned `AM-1` read as covered by a report mentioning only
540
+ // `AM-10` (MEASURED — `'AM-10'.includes('AM-1')` `true`). A substring is not an id.
541
+ const mentionedInQe = new Set(amendmentIdsIn(qeText));
542
+ const covered = plannedAm.filter((id) => mentionedInQe.has(id));
543
+ const missing = plannedAm.filter((id) => !mentionedInQe.has(id));
399
544
  add(
400
545
  'amendment-confirmation',
401
546
  'amendments carried into QE with confirmation',
@@ -414,7 +559,7 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
414
559
  (grade !== null ? ` · QE grade ${grade}` : ' · no QE grade') +
415
560
  (worst.length > 0 ? ` · absent: ${worst.join(', ')}` : '');
416
561
 
417
- return { slug, disciplines, qeGrade: grade, passed, total, summary };
562
+ return { slug, disciplines, qeGrade: grade, mutationEvidence, passed, total, summary };
418
563
  }
419
564
 
420
565
  const MARK: Record<DisciplineVerdict, string> = { pass: '✓', partial: '◐', absent: '✗' };
@@ -427,7 +572,232 @@ export function renderScorecard(card: RunScorecard): string {
427
572
  out.push(` ${MARK[d.verdict]} ${d.title}`);
428
573
  out.push(` ${d.evidence}`);
429
574
  }
575
+ // F30а-3: the table's own verdict is reported INDEPENDENTLY of which path scored `discrimination`.
576
+ // When prose carries the pass, the discipline's evidence line shows the prose — and a table that
577
+ // proved nothing would sit in the report unmentioned, beside a sentence claiming the mutants died.
578
+ // Silence about a hollow table is the failure this feature exists to prevent, so it is stated here
579
+ // even when it changes no verdict. No table at all stays silent: the common case earns no noise.
580
+ if (card.mutationEvidence !== undefined && card.mutationEvidence.status !== 'absent') {
581
+ out.push('');
582
+ out.push(` ${card.mutationEvidence.evidence}`);
583
+ }
430
584
  out.push('');
431
585
  out.push(` ${card.summary}`);
432
586
  return out.join('\n');
433
587
  }
588
+
589
+ /** The append-only projection of one immutable `score-<qeHash>.json` receipt. */
590
+ export interface ScoreAggregateRow {
591
+ readonly ts: string;
592
+ readonly slug: string;
593
+ readonly qeHash: string;
594
+ readonly passed: number;
595
+ readonly total: number;
596
+ readonly qeGrade: string | null;
597
+ readonly disciplines: readonly {
598
+ readonly id: string;
599
+ readonly verdict: DisciplineVerdict;
600
+ }[];
601
+ /**
602
+ * F30а-3, ADDITIVE: rows PROVEN by the mutation gate, when the scorecard carried a table.
603
+ * `undefined` means the scorecard had no mutation field at all — which is NOT the same as `0`
604
+ * (a table that ran and proved nothing). Every row written before this field existed keeps
605
+ * parsing: the aggregate must never lose its history to a schema change.
606
+ */
607
+ readonly mutationProven?: number;
608
+ }
609
+
610
+ export interface ScoreReceiptInput {
611
+ readonly content: string;
612
+ readonly qeHash: string;
613
+ /** Supplied by the impure caller. Receipt projection never reads a clock. */
614
+ readonly ts: string;
615
+ }
616
+
617
+ function isDisciplineVerdict(value: unknown): value is DisciplineVerdict {
618
+ return value === 'pass' || value === 'partial' || value === 'absent';
619
+ }
620
+
621
+ function scoreAggregateRowFrom(value: unknown): ScoreAggregateRow | null {
622
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
623
+ const record = value as Record<string, unknown>;
624
+ const disciplinesValue = record['disciplines'];
625
+ if (!Array.isArray(disciplinesValue)) return null;
626
+ const disciplines: { id: string; verdict: DisciplineVerdict }[] = [];
627
+ const disciplineIds = new Set<string>();
628
+ for (const value of disciplinesValue) {
629
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
630
+ const discipline = value as Record<string, unknown>;
631
+ if (typeof discipline['id'] !== 'string' || discipline['id'].trim() === '') return null;
632
+ if (!isDisciplineVerdict(discipline['verdict'])) return null;
633
+ if (disciplineIds.has(discipline['id'])) return null;
634
+ disciplineIds.add(discipline['id']);
635
+ disciplines.push({ id: discipline['id'], verdict: discipline['verdict'] });
636
+ }
637
+ const passed = record['passed'];
638
+ const total = record['total'];
639
+ const qeGrade = record['qeGrade'];
640
+ if (typeof record['ts'] !== 'string' || record['ts'].trim() === '') return null;
641
+ if (typeof record['slug'] !== 'string' || record['slug'].trim() === '') return null;
642
+ if (typeof record['qeHash'] !== 'string' || record['qeHash'].trim() === '') return null;
643
+ if (typeof passed !== 'number' || !Number.isSafeInteger(passed) || passed < 0) return null;
644
+ if (typeof total !== 'number' || !Number.isSafeInteger(total) || total < 0) return null;
645
+ if (qeGrade !== null && (typeof qeGrade !== 'string' || qeGrade.trim() === '')) return null;
646
+ if (total !== disciplines.length) return null;
647
+ if (passed !== disciplines.filter((discipline) => discipline.verdict === 'pass').length) return null;
648
+ return {
649
+ ts: record['ts'],
650
+ slug: record['slug'],
651
+ qeHash: record['qeHash'],
652
+ passed,
653
+ total,
654
+ qeGrade,
655
+ disciplines,
656
+ };
657
+ }
658
+
659
+ /**
660
+ * Parse one score receipt into the deliberately small aggregate schema.
661
+ *
662
+ * The caller owns I/O and supplies `ts`; this function is deterministic for the same input. A
663
+ * syntactically valid but internally inconsistent scorecard is unreadable evidence, not a row the
664
+ * aggregate should silently bless.
665
+ */
666
+ export function scoreReceiptToAggregateRow(input: ScoreReceiptInput): ScoreAggregateRow {
667
+ let parsed: unknown;
668
+ try {
669
+ parsed = JSON.parse(input.content) as unknown;
670
+ } catch {
671
+ throw new Error('score receipt is not valid JSON');
672
+ }
673
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
674
+ throw new Error('score receipt is not a RunScorecard object');
675
+ }
676
+ const receipt = parsed as Record<string, unknown>;
677
+ const row = scoreAggregateRowFrom({ ...receipt, ts: input.ts, qeHash: input.qeHash });
678
+ if (row === null) throw new Error('score receipt has an invalid RunScorecard shape');
679
+ // Projected here rather than inside scoreAggregateRowFrom so that a MALFORMED mutation field can
680
+ // never invalidate an otherwise-good row: the field is additive, so its absence — or its
681
+ // unreadability — costs the row nothing but the field itself.
682
+ const ev = receipt['mutationEvidence'];
683
+ if (typeof ev === 'object' && ev !== null && !Array.isArray(ev)) {
684
+ const proven = (ev as Record<string, unknown>)['proven'];
685
+ if (typeof proven === 'number' && Number.isFinite(proven) && proven >= 0) {
686
+ return { ...row, mutationProven: proven };
687
+ }
688
+ }
689
+ return row;
690
+ }
691
+
692
+ /** Read only valid aggregate rows; event-chain verification separately names malformed lines. */
693
+ export function readScoreAggregateRows(text: string): ScoreAggregateRow[] {
694
+ const rows: ScoreAggregateRow[] = [];
695
+ for (const line of String(text).split('\n')) {
696
+ if (line.trim() === '') continue;
697
+ try {
698
+ const row = scoreAggregateRowFrom(JSON.parse(line) as unknown);
699
+ if (row !== null) rows.push(row);
700
+ } catch {
701
+ /* A torn line is chain evidence, not a row. verifyEventChainText names it. */
702
+ }
703
+ }
704
+ return rows;
705
+ }
706
+
707
+ /** Keep the first occurrence of each `(slug, qeHash)` pair not already present in the aggregate. */
708
+ export function dedupeScoreAggregateRows(
709
+ candidates: readonly ScoreAggregateRow[],
710
+ existing: readonly ScoreAggregateRow[],
711
+ ): ScoreAggregateRow[] {
712
+ const seen = new Set(existing.map((row) => `${row.slug}\u0000${row.qeHash}`));
713
+ const fresh: ScoreAggregateRow[] = [];
714
+ for (const row of candidates) {
715
+ const key = `${row.slug}\u0000${row.qeHash}`;
716
+ if (seen.has(key)) continue;
717
+ seen.add(key);
718
+ fresh.push(row);
719
+ }
720
+ return fresh;
721
+ }
722
+
723
+ export type ScoreAggregateVerdict = 'REPORTED' | 'INSUFFICIENT_DATA';
724
+
725
+ export interface ScoreDisciplineAggregate {
726
+ readonly id: string;
727
+ readonly pass: number;
728
+ readonly partial: number;
729
+ readonly absent: number;
730
+ }
731
+
732
+ export interface ScoreGradeAggregate {
733
+ readonly grade: string | null;
734
+ readonly count: number;
735
+ }
736
+
737
+ export interface ScoreAggregateReport {
738
+ readonly verdict: ScoreAggregateVerdict;
739
+ readonly receipts: number;
740
+ readonly appended: number;
741
+ readonly unreadable: number;
742
+ /** Repo-relative receipt paths: unreadable evidence is always named, never only counted. */
743
+ readonly unreadableReceipts: readonly string[];
744
+ readonly disciplines: readonly ScoreDisciplineAggregate[];
745
+ readonly grades: readonly ScoreGradeAggregate[];
746
+ }
747
+
748
+ /** Fold the aggregate into an advisory report. No readable rows is a third state, never success. */
749
+ export function buildScoreAggregateReport(
750
+ rows: readonly ScoreAggregateRow[],
751
+ unreadableReceipts: readonly string[],
752
+ appended: number,
753
+ ): ScoreAggregateReport {
754
+ const disciplineCounts = new Map<string, { pass: number; partial: number; absent: number }>();
755
+ const gradeCounts = new Map<string | null, number>();
756
+ for (const row of rows) {
757
+ gradeCounts.set(row.qeGrade, (gradeCounts.get(row.qeGrade) ?? 0) + 1);
758
+ for (const discipline of row.disciplines) {
759
+ const counts = disciplineCounts.get(discipline.id) ?? { pass: 0, partial: 0, absent: 0 };
760
+ counts[discipline.verdict] += 1;
761
+ disciplineCounts.set(discipline.id, counts);
762
+ }
763
+ }
764
+ const disciplines = [...disciplineCounts.entries()]
765
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
766
+ .map(([id, counts]) => ({ id, ...counts }));
767
+ const grades = [...gradeCounts.entries()]
768
+ .sort(([a], [b]) => (a === null ? 1 : b === null ? -1 : a < b ? -1 : a > b ? 1 : 0))
769
+ .map(([grade, count]) => ({ grade, count }));
770
+ return {
771
+ verdict: rows.length === 0 ? 'INSUFFICIENT_DATA' : 'REPORTED',
772
+ receipts: rows.length + unreadableReceipts.length,
773
+ appended,
774
+ unreadable: unreadableReceipts.length,
775
+ unreadableReceipts: [...unreadableReceipts],
776
+ disciplines,
777
+ grades,
778
+ };
779
+ }
780
+
781
+ export function renderScoreAggregateReport(report: ScoreAggregateReport): string {
782
+ const out = [
783
+ 'dz score --all — chained score-receipt aggregate (descriptive-only, never a gate)',
784
+ `VERDICT: ${report.verdict}`,
785
+ `receipts: ${report.receipts}`,
786
+ `appended: ${report.appended}`,
787
+ `unreadable: ${report.unreadable}`,
788
+ ];
789
+ if (report.unreadableReceipts.length > 0) {
790
+ out.push(`unreadable receipts: ${report.unreadableReceipts.join(', ')}`);
791
+ }
792
+ if (report.verdict === 'INSUFFICIENT_DATA') {
793
+ out.push('No readable score receipts; no discipline ratio or percentage is reported.');
794
+ return out.join('\n');
795
+ }
796
+ out.push('disciplines:');
797
+ for (const discipline of report.disciplines) {
798
+ out.push(` ${discipline.id}: pass ${discipline.pass} · partial ${discipline.partial} · absent ${discipline.absent}`);
799
+ }
800
+ out.push('QE grades:');
801
+ for (const grade of report.grades) out.push(` ${grade.grade ?? '(no grade)'}: ${grade.count}`);
802
+ return out.join('\n');
803
+ }