@event4u/agent-config 12.0.0 → 13.0.0

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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +273 -0
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +46 -3
  5. package/dist/agent-src/commands/cost/report.md +23 -0
  6. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  8. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  10. package/dist/agent-src/rules/design-fidelity.md +1 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  12. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  13. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  14. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  15. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  17. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  18. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  19. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
  20. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  21. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  22. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  23. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  24. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
  26. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  27. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +41 -3
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
  33. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  34. package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
  35. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
  36. package/dist/cli-delegate/cmd_conformance.js +4 -4
  37. package/dist/cli-delegate/cmd_doctor.js +2 -2
  38. package/dist/cli-delegate/cmd_export.js +2 -2
  39. package/dist/cli-delegate/cmd_handoff.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  42. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  43. package/dist/cli-delegate/cmd_sync.js +2 -2
  44. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  45. package/dist/cli-delegate/cmd_validate.js +2 -2
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +44 -23
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +3 -3
  50. package/dist/discovery/dormancy-report.md +1 -1
  51. package/dist/discovery/orphan-report.md +1 -1
  52. package/dist/discovery/packs.json +6 -5
  53. package/dist/discovery/trust-report.md +2 -2
  54. package/dist/discovery/workspaces.json +5 -4
  55. package/dist/hooks/dispatch.js +10625 -8541
  56. package/dist/install/install.mjs +754 -484
  57. package/dist/mcp/registry-manifest.json +3 -3
  58. package/dist/mcp/server.json +2 -2
  59. package/dist/mcp/server.mjs +1302 -167
  60. package/dist/router.json +1 -1
  61. package/dist/scripts/_lib/model_tier.js +9 -0
  62. package/dist/scripts/_lib/model_tier.js.map +1 -1
  63. package/dist/scripts/condense.js +50 -0
  64. package/dist/scripts/condense.js.map +1 -1
  65. package/dist/server/schemas/settings.js +2 -2
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/MIGRATION.md +20 -1
  68. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  69. package/docs/guidelines/design-handover-extraction.md +87 -0
  70. package/docs/guidelines/prompt-templates.md +24 -1
  71. package/package.json +1 -1
  72. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  73. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
  75. package/src/config/evaluator-budgets.json +9 -8
  76. package/src/config/gate-coverage.yml +63 -11
  77. package/src/config/gate-violation-baselines.json +12 -0
  78. package/src/config/hook-token-budget.json +15 -2
  79. package/src/config/preamble-payload-budget.json +28 -0
  80. package/src/scripts/_dispatch.bash +10 -0
  81. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  82. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  83. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  84. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  85. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  86. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  87. package/src/scripts/_lib/git_common_dir.ts +173 -0
  88. package/src/scripts/_lib/model_tier.ts +9 -0
  89. package/src/scripts/_lib/release_highlights.ts +108 -7
  90. package/src/scripts/_lib/skill_catalogue.ts +1115 -0
  91. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  92. package/src/scripts/ai_council/clients.ts +197 -18
  93. package/src/scripts/ai_council/config.ts +16 -1
  94. package/src/scripts/ai_council/low_impact.ts +14 -1
  95. package/src/scripts/ai_council/orchestrator.ts +14 -1
  96. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  97. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  98. package/src/scripts/bench_ab_v2_run.ts +3 -42
  99. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  100. package/src/scripts/bench_ab_v2_search.ts +343 -0
  101. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  102. package/src/scripts/build_archive_index.ts +383 -0
  103. package/src/scripts/capture_skill_catalogue.ts +279 -317
  104. package/src/scripts/check_branch_freshness.ts +7 -1
  105. package/src/scripts/check_release_published.ts +6 -2
  106. package/src/scripts/check_release_trunk_sync.ts +18 -2
  107. package/src/scripts/check_source_size_budget.ts +428 -0
  108. package/src/scripts/check_suppression_hygiene.ts +5 -1
  109. package/src/scripts/condense.ts +50 -1
  110. package/src/scripts/cost/backfill_rates.mjs +298 -0
  111. package/src/scripts/cost/budget.mjs +12 -32
  112. package/src/scripts/cost_summary.ts +25 -0
  113. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  114. package/src/scripts/evidence_report.ts +15 -4
  115. package/src/scripts/external_sources_denylist.json +1 -0
  116. package/src/scripts/hook_manifest.yaml +75 -15
  117. package/src/scripts/hooks/concern_registry.ts +4 -0
  118. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  119. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  120. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  121. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  122. package/src/scripts/hot_context_hook.ts +13 -1
  123. package/src/scripts/install.ts +129 -0
  124. package/src/scripts/janitor.ts +77 -2
  125. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  126. package/src/scripts/lint_harvest_provenance.ts +35 -1
  127. package/src/scripts/lint_plan_risk_register.ts +22 -9
  128. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  129. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  130. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  131. package/src/scripts/lint_skill_router_head.ts +21 -0
  132. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  133. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  134. package/src/scripts/mcp_server/tools.ts +87 -0
  135. package/src/scripts/migration_status.ts +17 -1
  136. package/src/scripts/probe_review_binding_drift.ts +731 -0
  137. package/src/scripts/release.ts +100 -6
  138. package/src/scripts/routing_doctor.ts +50 -13
  139. package/src/scripts/schemas/command.schema.json +3 -3
  140. package/src/scripts/schemas/skill.schema.json +20 -2
  141. package/src/scripts/schemas/subagent.schema.json +2 -2
  142. package/src/scripts/session_eol_report.ts +81 -0
  143. package/src/scripts/trigger_coverage.ts +224 -3
  144. package/src/scripts/workspace_doctor.ts +464 -0
  145. package/src/scripts/worktree_cleanup_check.ts +2 -1
  146. package/src/server/schemas/settings.ts +2 -2
  147. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
  148. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,423 @@
1
+ /**
2
+ * T5 — search adherence, rubric-judged at k=2.
3
+ *
4
+ * The pre-registration (`internal/bench/ab-v2-phase3-PREREG.md` § Thresholds)
5
+ * defines T5 as *"search-adherence (rubric-judged, k=2)"* with the rationale
6
+ * from F8: the ladder enforces a **search** discipline, while lines-of-code
7
+ * measures output volume. A run can shrink a diff without anyone having looked
8
+ * for an existing mechanism, so a size-only report measures the wrong
9
+ * hypothesis. This module scores the question the roadmap asks — *did the run
10
+ * demonstrably consider a cheaper existing mechanism before writing new code* —
11
+ * off the trial's own transcript.
12
+ *
13
+ * WHY A RUBRIC HERE AND AN EXECUTION PROBE FOR T4. The two endpoints are not
14
+ * the same shape and the pre-registration already says so, tagging only this
15
+ * one `rubric-judged`. T4 asks whether a property still holds in the running
16
+ * code, which an interpreter answers. T5 asks what the run *did before it
17
+ * wrote* — an act with no residue in the final tree at all. There is nothing to
18
+ * execute; the transcript is the only evidence, and reading it is a judgement.
19
+ *
20
+ * WHY k=2 AND WHY THE AGGREGATION IS CONSERVATIVE. One judge on a
21
+ * judgement-shaped rubric is a single point of failure whose error is invisible.
22
+ * Two judges make disagreement observable, and `resolveK2` counts an item as
23
+ * satisfied only when **both** agree it was — the same asymmetry
24
+ * `_lib/anchor_eval.ts` uses, and for the same reason: the failure that matters
25
+ * is crediting adherence that did not happen. Crediting it needs unanimity;
26
+ * denying it needs one.
27
+ *
28
+ * NULL, NEVER ZERO. A judge that cannot answer after its one retry makes the
29
+ * TRIAL unmeasured, not zero-scoring. A zero would say "this run looked for
30
+ * nothing", which is a claim about the run; the truth is a claim about the
31
+ * measurement. `compare()` requires the metric on both sides of a pair, so an
32
+ * unmeasured trial removes its pair rather than biasing it.
33
+ *
34
+ * PURE BY CONSTRUCTION. Nothing here imports a model client. The judge arrives
35
+ * as an injected `AskFn`, which is what lets every property below be pinned in
36
+ * a unit test with no key, no network, and no spend — the idiom
37
+ * `bench_quality_run.ts` and `anchor_eval.ts` already use.
38
+ */
39
+ import { ALPHA, type PairedContinuous } from './bench_ab_size_claim.js';
40
+
41
+ /** The three rubric items, in the order the prompt presents them. */
42
+ export const SEARCH_RUBRIC_ITEMS = ['named', 'inspected', 'justified'] as const;
43
+ export type SearchRubricItem = (typeof SEARCH_RUBRIC_ITEMS)[number];
44
+
45
+ /** Pre-registered judge count. */
46
+ export const SEARCH_ADHERENCE_K = 2;
47
+
48
+ /** A judge: one prompt in, one raw completion out. */
49
+ export type AskFn = (prompt: string) => string;
50
+
51
+ export interface JudgeReading {
52
+ /** Per item: `true` satisfied, `false` not, `null` unparseable. */
53
+ items: Record<SearchRubricItem, boolean | null>;
54
+ /** True when every item parsed to a boolean. */
55
+ complete: boolean;
56
+ }
57
+
58
+ export interface SearchAdherenceResult {
59
+ /** Fraction of rubric items both judges credited, or `null` if unmeasured. */
60
+ score: number | null;
61
+ readings: JudgeReading[];
62
+ reason: string;
63
+ }
64
+
65
+ /**
66
+ * Delimiters around the transcript inside the judge prompt.
67
+ *
68
+ * They exist because the RUBRIC that follows the transcript necessarily
69
+ * contains the very words the rubric asks about — "reuse", "existing", "read",
70
+ * "grepped", "because". Any reader that slices the prompt at `TRANSCRIPT:` and
71
+ * takes the rest is therefore reading the instrument, not the evidence, and
72
+ * scores every input at the ceiling. That is not hypothetical: the dry-run
73
+ * judge did exactly this on its first pass and its own test caught it.
74
+ *
75
+ * The hazard runs in BOTH directions and only one of them is a parsing bug. A
76
+ * transcript is untrusted model output pasted into a judge prompt, and
77
+ * delimiters are the only separation there is — nothing stops transcript text
78
+ * from addressing the judge directly. That is `untrusted-input-defense`'s
79
+ * territory rather than this module's, and it is named here so a reader does not
80
+ * mistake the delimiters for a containment guarantee they are not.
81
+ */
82
+ export const TRANSCRIPT_OPEN = '--- BEGIN TRANSCRIPT ---';
83
+ export const TRANSCRIPT_CLOSE = '--- END TRANSCRIPT ---';
84
+
85
+ /**
86
+ * Extract the transcript back out of a built prompt.
87
+ *
88
+ * Returns `''` when the delimiters are absent, which yields a credit-nothing
89
+ * reading rather than a ceiling one — the safe direction for a rubric whose
90
+ * failure mode is crediting adherence that did not happen.
91
+ */
92
+ export function transcriptFromPrompt(prompt: string): string {
93
+ const start = prompt.indexOf(TRANSCRIPT_OPEN);
94
+ // LAST close, not the first: a transcript is untrusted model output and may
95
+ // contain the delimiter literally. Taking the first occurrence would let a
96
+ // transcript truncate the evidence a reader extracts from it.
97
+ const end = prompt.lastIndexOf(TRANSCRIPT_CLOSE);
98
+ if (start === -1 || end === -1 || end <= start) return '';
99
+ return prompt.slice(start + TRANSCRIPT_OPEN.length, end);
100
+ }
101
+
102
+ /**
103
+ * Build the judge prompt.
104
+ *
105
+ * Three properties are deliberate. The rubric is **stated as evidence tests**,
106
+ * not as a quality opinion — each item asks whether a specific thing appears in
107
+ * the transcript, so two judges are reading for the same artefact rather than
108
+ * forming the same taste. The output shape is a fixed three-line block, which
109
+ * makes an incomplete answer detectable instead of silently half-parsed. And it
110
+ * says explicitly not to reward brevity: T5 exists precisely because a shorter
111
+ * diff is not evidence of a search, so a judge rewarding shortness here would
112
+ * launder T1 into T5 and destroy the independence the pair depends on.
113
+ */
114
+ export function searchAdherencePrompt(taskPrompt: string, transcript: string): string {
115
+ return [
116
+ 'You are scoring one transcript against a three-item evidence rubric.',
117
+ 'Answer only from what the transcript shows. Do not infer intent, and do',
118
+ 'not reward or penalise how long, short, or elegant the resulting change is.',
119
+ '',
120
+ 'TASK GIVEN TO THE RUN:',
121
+ taskPrompt,
122
+ '',
123
+ TRANSCRIPT_OPEN,
124
+ transcript,
125
+ TRANSCRIPT_CLOSE,
126
+ '',
127
+ 'RUBRIC — for each item answer yes or no:',
128
+ 'NAMED: before writing new code, the run named a specific existing mechanism',
129
+ ' it could reuse instead (a file, function, class, library, or built-in).',
130
+ ' A generic statement that something might already exist is NOT enough.',
131
+ 'INSPECTED: the run actually looked at that mechanism — read, listed, grepped,',
132
+ ' or opened it — rather than only mentioning it.',
133
+ 'JUSTIFIED: the run stated why it did or did not reuse what it found.',
134
+ '',
135
+ 'Answer with exactly these three lines and nothing else:',
136
+ 'NAMED: yes|no',
137
+ 'INSPECTED: yes|no',
138
+ 'JUSTIFIED: yes|no',
139
+ ].join('\n');
140
+ }
141
+
142
+ // Leading whitespace is tolerated on purpose: a judge that indents its block,
143
+ // or emits it inside a list, has still answered. Anchoring hard at column 0
144
+ // dropped exactly that item to `null`, which then reads as "incomplete reading"
145
+ // and discards a trial for a formatting habit.
146
+ const _LINE = /^[ \t>*-]*(NAMED|INSPECTED|JUSTIFIED)\s*:\s*(yes|no)\b/gim;
147
+ const _KEY_TO_ITEM: Record<string, SearchRubricItem> = {
148
+ NAMED: 'named',
149
+ INSPECTED: 'inspected',
150
+ JUSTIFIED: 'justified',
151
+ };
152
+
153
+ /**
154
+ * Parse one judge completion.
155
+ *
156
+ * Unmatched items stay `null` and `complete` goes false — the caller retries
157
+ * once and then gives up on the trial. Defaulting a missing line to `no` would
158
+ * be the cheap read and the wrong one: it converts "the judge did not answer"
159
+ * into "the run did not search", which is a different claim and the one this
160
+ * endpoint is measuring.
161
+ */
162
+ export function parseSearchVerdict(text: string): JudgeReading {
163
+ const items: Record<SearchRubricItem, boolean | null> = {
164
+ named: null,
165
+ inspected: null,
166
+ justified: null,
167
+ };
168
+ _LINE.lastIndex = 0;
169
+ let m: RegExpExecArray | null = _LINE.exec(text ?? '');
170
+ while (m !== null) {
171
+ const item = _KEY_TO_ITEM[(m[1] ?? '').toUpperCase()];
172
+ if (item !== undefined && items[item] === null) {
173
+ items[item] = (m[2] ?? '').toLowerCase() === 'yes';
174
+ }
175
+ m = _LINE.exec(text ?? '');
176
+ }
177
+ const complete = SEARCH_RUBRIC_ITEMS.every((k) => items[k] !== null);
178
+ return { items, complete };
179
+ }
180
+
181
+ /**
182
+ * Combine two readings — an item counts only when both judges credit it.
183
+ *
184
+ * Returns the satisfied-item count, never a score, so the caller owns the
185
+ * denominator. A `null` on either side is not a credit, which is the same
186
+ * direction as a `no`: the burden is on evidence of a search, not on evidence
187
+ * of its absence.
188
+ */
189
+ export function resolveK2(a: JudgeReading, b: JudgeReading): number {
190
+ let credited = 0;
191
+ for (const key of SEARCH_RUBRIC_ITEMS) {
192
+ if (a.items[key] === true && b.items[key] === true) credited += 1;
193
+ }
194
+ return credited;
195
+ }
196
+
197
+ /**
198
+ * Ask one judge, with exactly one retry on an unparseable or partial answer.
199
+ *
200
+ * One retry, not a loop: a judge that produced half a block twice is telling us
201
+ * something about the prompt or the model, and burning further calls on it
202
+ * would spend money to keep a bad reading warm.
203
+ */
204
+ export function askWithRetry(ask: AskFn, prompt: string): JudgeReading {
205
+ let reading: JudgeReading;
206
+ try {
207
+ reading = parseSearchVerdict(ask(prompt));
208
+ } catch {
209
+ reading = { items: { named: null, inspected: null, justified: null }, complete: false };
210
+ }
211
+ if (reading.complete) return reading;
212
+ try {
213
+ return parseSearchVerdict(ask(prompt));
214
+ } catch {
215
+ return { items: { named: null, inspected: null, justified: null }, complete: false };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Score one trial at k=2.
221
+ *
222
+ * `asks` carries one judge per element — two distinct judges is the pre-
223
+ * registered shape, and passing the SAME function twice is accepted rather than
224
+ * rejected because that is the legitimate degraded case (one vendor reachable);
225
+ * the caller records which it used. Fewer than two, or any judge that never
226
+ * produced a complete reading, yields `null`.
227
+ */
228
+ export function scoreSearchAdherence(opts: {
229
+ asks: AskFn[];
230
+ taskPrompt: string;
231
+ transcript: string;
232
+ }): SearchAdherenceResult {
233
+ const { asks, taskPrompt, transcript } = opts;
234
+ if (asks.length < SEARCH_ADHERENCE_K) {
235
+ return { score: null, readings: [], reason: `fewer than k=${SEARCH_ADHERENCE_K} judges supplied` };
236
+ }
237
+ if (!transcript || transcript.trim() === '') {
238
+ return { score: null, readings: [], reason: 'no transcript recorded for this trial' };
239
+ }
240
+ const prompt = searchAdherencePrompt(taskPrompt, transcript);
241
+ const readings = asks.slice(0, SEARCH_ADHERENCE_K).map((ask) => askWithRetry(ask, prompt));
242
+ const incomplete = readings.filter((r) => !r.complete).length;
243
+ if (incomplete > 0) {
244
+ return {
245
+ score: null,
246
+ readings,
247
+ reason: `${incomplete} of ${readings.length} judges returned an incomplete reading after one retry`,
248
+ };
249
+ }
250
+ const credited = resolveK2(readings[0] as JudgeReading, readings[1] as JudgeReading);
251
+ return {
252
+ score: credited / SEARCH_RUBRIC_ITEMS.length,
253
+ readings,
254
+ reason: `${credited}/${SEARCH_RUBRIC_ITEMS.length} rubric items credited by both judges`,
255
+ };
256
+ }
257
+
258
+ export interface SearchClaimInput {
259
+ arm_treatment: string | null;
260
+ arm_baseline: string | null;
261
+ search: PairedContinuous;
262
+ }
263
+
264
+ export interface SearchClaimVerdict {
265
+ arm_treatment: string | null;
266
+ arm_baseline: string | null;
267
+ verdict: 'PASS' | 'REFUSED-SEARCH-REGRESSION' | 'INCONCLUSIVE';
268
+ reason: string;
269
+ search_measured: boolean;
270
+ }
271
+
272
+ /**
273
+ * Evaluate T5 for one comparison.
274
+ *
275
+ * The pre-registered threshold is *no significant regression*, two-sided at
276
+ * p < 0.05 in the harmful direction — so `PASS` is "the test did not reject",
277
+ * NOT "the treatment scored higher". Reading a non-rejection as a win is the
278
+ * mistake this comment exists to prevent: T5 is a guard on the size claim, not
279
+ * a second thing the ladder gets to win.
280
+ *
281
+ * An unmeasured endpoint is `INCONCLUSIVE`, never a pass — the same contract
282
+ * `evaluateSizeClaim` holds for T1/T2/T4.
283
+ */
284
+ export function evaluateSearchAdherence(input: SearchClaimInput): SearchClaimVerdict {
285
+ const { search } = input;
286
+ const base = { arm_treatment: input.arm_treatment, arm_baseline: input.arm_baseline };
287
+ if (!search.measured) {
288
+ return {
289
+ ...base,
290
+ verdict: 'INCONCLUSIVE',
291
+ reason: 'T5 search-adherence not measured — no analysed pair carried it on both sides',
292
+ search_measured: false,
293
+ };
294
+ }
295
+ if (search.median_delta < 0 && search.wilcoxon_p < ALPHA) {
296
+ return {
297
+ ...base,
298
+ verdict: 'REFUSED-SEARCH-REGRESSION',
299
+ reason:
300
+ 'search adherence fell significantly (T5) — the ladder enforces a search ' +
301
+ 'discipline, so a size result bought by skipping it is not the hypothesis under test',
302
+ search_measured: true,
303
+ };
304
+ }
305
+ return {
306
+ ...base,
307
+ verdict: 'PASS',
308
+ reason: 'no significant regression in search adherence (T5)',
309
+ search_measured: true,
310
+ };
311
+ }
312
+
313
+ /** Render the T5 section of the stats report. */
314
+ export function renderSearchAdherenceSection(verdicts: readonly SearchClaimVerdict[]): string[] {
315
+ const L: string[] = [];
316
+ L.push('## T5 — search adherence (rubric-judged, k=2)');
317
+ L.push('');
318
+ if (verdicts.length === 0) {
319
+ L.push('No comparison produced a search-adherence verdict.');
320
+ return L;
321
+ }
322
+ L.push('| treatment | baseline | verdict | reason |');
323
+ L.push('|---|---|---|---|');
324
+ for (const v of verdicts) {
325
+ L.push(
326
+ `| ${v.arm_treatment ?? '-'} | ${v.arm_baseline ?? '-'} | ${v.verdict} | ${v.reason} |`,
327
+ );
328
+ }
329
+ L.push('');
330
+ L.push(
331
+ '`PASS` means the pre-registered test did not reject in the harmful direction. ' +
332
+ 'It is not a claim that the treatment searched *more*.',
333
+ );
334
+ return L;
335
+ }
336
+
337
+ /**
338
+ * Adapters for the report pipeline — the `Dict`-shaped objects
339
+ * `bench_ab_v2_stats` speaks, coerced into the typed input above.
340
+ *
341
+ * They live here rather than in the stats module for the same reason the
342
+ * verdict does: the dependency runs one way (stats imports this; this imports
343
+ * nothing from stats), and the size ratchet over `src/**` counts every line
344
+ * added to an already-large file. Extraction is the shrink-only ratchet's
345
+ * intended move; trimming the prose out of a comment is not.
346
+ */
347
+ type Dict = Record<string, unknown>;
348
+
349
+ /**
350
+ * Coerce a report value to a number, unwrapping the stats module's float box.
351
+ *
352
+ * `_paired_median_block` wraps every figure in a `PyFloat` so integers render
353
+ * with a trailing `.0`, and `Number(thatObject)` is `NaN`. Reading it as `0`
354
+ * turned a −1 median delta into no delta at all, so T5 reported PASS on a
355
+ * corpus where adherence had collapsed — the adapter test caught it, which is
356
+ * what that test exists for. The check is structural rather than an
357
+ * `instanceof`: the class is private to the stats module, and importing it here
358
+ * would reverse the dependency direction this file is careful to keep one-way.
359
+ */
360
+ function _num(v: unknown): number {
361
+ const raw =
362
+ v !== null && typeof v === 'object' && 'value' in (v as Record<string, unknown>)
363
+ ? (v as { value: unknown }).value
364
+ : v;
365
+ const n = Number(raw);
366
+ return Number.isFinite(n) ? n : 0;
367
+ }
368
+
369
+ function _dict(v: unknown): Dict {
370
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Dict) : {};
371
+ }
372
+
373
+ /** The Phase-3 T5 verdict for one `compare()` block. */
374
+ export function searchClaimVerdict(comparison: Dict): Dict {
375
+ const block = _dict(comparison['search']);
376
+ return evaluateSearchAdherence({
377
+ arm_treatment: comparison['arm_treatment'] == null ? null : String(comparison['arm_treatment']),
378
+ arm_baseline: comparison['arm_baseline'] == null ? null : String(comparison['arm_baseline']),
379
+ search: {
380
+ measured: block['measured'] === true,
381
+ n_pairs: _num(block['n_pairs']),
382
+ median_delta_pct: block['median_delta_pct'] == null ? null : _num(block['median_delta_pct']),
383
+ median_delta: _num(block['median_delta']),
384
+ wilcoxon_p: _num(block['wilcoxon_p']),
385
+ },
386
+ }) as unknown as Dict;
387
+ }
388
+
389
+ /** The T5 section for one `analyse()` payload. */
390
+ export function searchClaimSection(analysis: Dict): string[] {
391
+ const claims = Array.isArray(analysis['search_claims'])
392
+ ? (analysis['search_claims'] as unknown as SearchClaimVerdict[])
393
+ : [];
394
+ return renderSearchAdherenceSection(claims);
395
+ }
396
+
397
+ /**
398
+ * A paired-sample accumulator for T5's continuous endpoint.
399
+ *
400
+ * `compare()` collects three parallel arrays per endpoint; T5 needs the same
401
+ * three and nothing more, so they live behind one object rather than as three
402
+ * more locals in a module the size ratchet already flags. `addPair` keeps the
403
+ * both-sides-or-neither rule in one place: a metric present on only one side of
404
+ * a pair contributes nothing, because half a pair is no observation.
405
+ */
406
+ export interface PairedSample {
407
+ t: number[];
408
+ b: number[];
409
+ diffs: number[];
410
+ }
411
+
412
+ export function newPairedSample(): PairedSample {
413
+ return { t: [], b: [], diffs: [] };
414
+ }
415
+
416
+ export function addPair(sample: PairedSample, treatment: unknown, baseline: unknown): void {
417
+ const a = typeof treatment === 'number' && Number.isFinite(treatment) ? treatment : null;
418
+ const b = typeof baseline === 'number' && Number.isFinite(baseline) ? baseline : null;
419
+ if (a === null || b === null) return;
420
+ sample.t.push(a);
421
+ sample.b.push(b);
422
+ sample.diffs.push(a - b);
423
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The Phase-3 size claim — the metric pair (T1 + T2) and the T4 disqualifier.
3
+ *
4
+ * Split out of `bench_ab_v2_stats.ts` because the size verdict is decision
5
+ * logic, not statistics: it consumes already-computed paired blocks and answers
6
+ * one question — may this arm claim a size win. Keeping it here makes it a pure
7
+ * function over typed input with no dependency on the report pipeline, which is
8
+ * also what keeps the dependency one-directional (stats imports this; this
9
+ * imports nothing from stats).
10
+ *
11
+ * Thresholds are the pre-registered ones
12
+ * (`internal/bench/ab-v2-phase3-PREREG.md` § Thresholds); they live here so a
13
+ * change to either has to touch the record and the code together.
14
+ */
15
+
16
+ /** T1 — median added lines must fall by at least this much. */
17
+ export const T1_MEDIAN_LINES_PCT = -10;
18
+ /** Two-sided significance for every endpoint in the pair. */
19
+ export const ALPHA = 0.05;
20
+
21
+ /** A paired continuous endpoint (added lines, cognitive complexity). */
22
+ export interface PairedContinuous {
23
+ measured: boolean;
24
+ n_pairs: number;
25
+ /** `null` when the baseline median is 0 — no percent change exists. */
26
+ median_delta_pct: number | null;
27
+ median_delta: number;
28
+ wilcoxon_p: number;
29
+ }
30
+
31
+ /** A paired binary endpoint (the safety tier). */
32
+ export interface PairedRate {
33
+ measured: boolean;
34
+ n_pairs: number;
35
+ rate_treatment: number;
36
+ rate_baseline: number;
37
+ mcnemar_p: number;
38
+ }
39
+
40
+ export interface SizeClaimInput {
41
+ arm_treatment: string | null;
42
+ arm_baseline: string | null;
43
+ size: PairedContinuous;
44
+ complexity: PairedContinuous;
45
+ safety: PairedRate;
46
+ }
47
+
48
+ export interface SizeClaimVerdict {
49
+ arm_treatment: string | null;
50
+ arm_baseline: string | null;
51
+ verdict: 'PASS' | 'NO-SIZE-WIN' | 'REFUSED-GOLFING' | 'REFUSED-SAFETY-REGRESSION' | 'INCONCLUSIVE';
52
+ reason: string;
53
+ lines_fell?: boolean;
54
+ size_measured: boolean;
55
+ complexity_measured: boolean;
56
+ safety_measured: boolean;
57
+ }
58
+
59
+ /**
60
+ * Evaluate the size claim for one comparison — binding, and structurally unable
61
+ * to report a win on size alone.
62
+ *
63
+ * Four properties, each a refusal the caller cannot route around:
64
+ *
65
+ * 1. **An unmeasured endpoint is never a pass.** If added lines, complexity,
66
+ * or the safety tier is absent, the verdict is `INCONCLUSIVE` naming which.
67
+ * Half a pair is no result, not a partial one — so this cannot silently
68
+ * degrade into the lines-only report F9 exists to forbid.
69
+ * 2. **Safety is checked FIRST and is a disqualifier**, not a side metric. An
70
+ * arm that saves a line and drops a guard has lost; there is no ordering of
71
+ * the other two endpoints that can overturn it.
72
+ * 2b. **Golfing is refused as soon as T2 shows it** — before the
73
+ * unmeasured-endpoint check, and independently of T4. The two disqualifiers
74
+ * answer different questions and neither waits for the other; ordering them
75
+ * the other way made `REFUSED-GOLFING` unreachable on real data, because no
76
+ * safety-tier producer exists in the tree.
77
+ * 3. **`PASS` is reachable through exactly one path** — all three measured,
78
+ * the complexity sample covering the size sample, neither disqualifier
79
+ * fired, and T1 met. That is the Goodhart guard in code rather than in
80
+ * prose: there is no branch that ranks on size while a safety regression
81
+ * stands.
82
+ */
83
+ export function evaluateSizeClaim(input: SizeClaimInput): SizeClaimVerdict {
84
+ const { size, complexity: cx, safety: saf } = input;
85
+ const base = { arm_treatment: input.arm_treatment, arm_baseline: input.arm_baseline };
86
+
87
+ // (2) The disqualifier runs before anything else can produce a win.
88
+ if (saf.measured && saf.rate_treatment < saf.rate_baseline && saf.mcnemar_p < ALPHA) {
89
+ return {
90
+ ...base,
91
+ verdict: 'REFUSED-SAFETY-REGRESSION',
92
+ reason:
93
+ 'safety tier regressed significantly (T4) — a size result is ' +
94
+ 'not reportable for this arm at all',
95
+ size_measured: size.measured,
96
+ complexity_measured: cx.measured,
97
+ safety_measured: true,
98
+ };
99
+ }
100
+
101
+ const cx_rose = cx.measured && cx.median_delta > 0 && cx.wilcoxon_p < ALPHA;
102
+ const pct = size.measured ? size.median_delta_pct : null;
103
+ const lines_fell = pct !== null && pct <= T1_MEDIAN_LINES_PCT && size.wilcoxon_p < ALPHA;
104
+
105
+ // (2b) Golfing is refused as soon as T2 shows it, and does NOT wait for T4.
106
+ //
107
+ // This ordering was wrong on the first pass and the completion review caught
108
+ // it: the unmeasured-endpoint branch ran first, so with no safety-tier
109
+ // producer in the tree — and there is none; T4's scorer is rubric-judged and
110
+ // unimplemented — every real run returned INCONCLUSIVE and `REFUSED-GOLFING`
111
+ // was reachable only from a synthetic fixture. A refusal that exists only in
112
+ // its own test is not a gate.
113
+ if (cx_rose) {
114
+ return {
115
+ ...base,
116
+ verdict: 'REFUSED-GOLFING',
117
+ reason:
118
+ 'median cognitive complexity per changed function rose significantly (T2); ' +
119
+ 'lines down and complexity up fails the size criterion even at p<0.05 on lines alone',
120
+ lines_fell,
121
+ size_measured: size.measured,
122
+ complexity_measured: true,
123
+ safety_measured: saf.measured,
124
+ };
125
+ }
126
+
127
+ const missing: string[] = [];
128
+ if (!size.measured) missing.push('T1 added-lines');
129
+ if (!cx.measured) missing.push('T2 cognitive-complexity');
130
+ if (!saf.measured) missing.push('T4 safety-tier');
131
+ if (missing.length > 0) {
132
+ return {
133
+ ...base,
134
+ verdict: 'INCONCLUSIVE',
135
+ reason: `endpoint(s) not measured: ${missing.join(', ')} — half a pair is no result`,
136
+ size_measured: size.measured,
137
+ complexity_measured: cx.measured,
138
+ safety_measured: saf.measured,
139
+ };
140
+ }
141
+
142
+ // (3) T1 and T2 are collected pair-wise and independently, so a trial that
143
+ // carries added lines but no complexity enters one sample and not the other.
144
+ // A win claimed on 30 pairs whose golfing check saw 4 is not a checked win —
145
+ // the guard would be policing a subset of the claim.
146
+ if (cx.n_pairs < size.n_pairs) {
147
+ return {
148
+ ...base,
149
+ verdict: 'INCONCLUSIVE',
150
+ reason:
151
+ `complexity sample (${cx.n_pairs} pairs) does not cover the size sample ` +
152
+ `(${size.n_pairs}) — the anti-golfing check would police a strict subset`,
153
+ lines_fell,
154
+ size_measured: true,
155
+ complexity_measured: true,
156
+ safety_measured: true,
157
+ };
158
+ }
159
+
160
+ return {
161
+ ...base,
162
+ verdict: lines_fell ? 'PASS' : 'NO-SIZE-WIN',
163
+ reason: lines_fell
164
+ ? `median added lines ${pct}% at p=${size.wilcoxon_p} with no significant complexity rise and no safety regression`
165
+ : `T1 not met (median added lines ${pct}% at p=${size.wilcoxon_p}; bar is <= ${T1_MEDIAN_LINES_PCT}% at p<${ALPHA})`,
166
+ lines_fell,
167
+ size_measured: true,
168
+ complexity_measured: true,
169
+ safety_measured: true,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The size-claim block of the rendered report.
175
+ *
176
+ * Rendered unconditionally — including when every endpoint is absent, because
177
+ * "the pair was not measurable" is the finding a reader needs in order not to
178
+ * quote a lines number from Table 1 as a size result.
179
+ */
180
+ export function renderSizeClaimSection(claims: readonly SizeClaimVerdict[]): string[] {
181
+ const L: string[] = [];
182
+ L.push('## Size claim (T1 + T2 pair, T4 disqualifier)');
183
+ L.push('');
184
+ L.push(
185
+ '> A size metric is a **measurement**, never a scored target. An arm may ' +
186
+ 'claim a size win only when median added lines fell **and** median ' +
187
+ 'cognitive complexity per changed function did not rise **and** the ' +
188
+ 'safety tier did not regress. An unmeasured endpoint is never a pass.',
189
+ );
190
+ L.push('');
191
+ if (claims.length === 0) {
192
+ L.push('_No comparison rendered — no arms to compare._');
193
+ L.push('');
194
+ return L;
195
+ }
196
+ L.push('| comparison | verdict | why |');
197
+ L.push('|---|---|---|');
198
+ for (const c of claims) {
199
+ L.push(
200
+ `| \`${c.arm_treatment}\` vs \`${c.arm_baseline}\` ` +
201
+ `| **${c.verdict}** | ${c.reason} |`,
202
+ );
203
+ }
204
+ L.push('');
205
+ return L;
206
+ }