@event4u/agent-config 12.1.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +151 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  6. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  8. package/dist/agent-src/rules/design-fidelity.md +1 -1
  9. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  10. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  11. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  12. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  13. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  14. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +17 -2
  16. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  18. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  19. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  20. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  21. package/dist/agent-src/skills/roadmap-writing/SKILL.md +4 -4
  22. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  23. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-3B72LSZL.js} +1 -1
  26. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  27. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-N7FEST6D.js} +17 -17
  28. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  29. package/dist/cli-delegate/cmd_conformance.js +2 -2
  30. package/dist/cli-delegate/cmd_doctor.js +2 -2
  31. package/dist/cli-delegate/cmd_export.js +2 -2
  32. package/dist/cli-delegate/cmd_handoff.js +1 -1
  33. package/dist/cli-delegate/cmd_prune.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  36. package/dist/cli-delegate/cmd_sync.js +2 -2
  37. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  38. package/dist/cli-delegate/cmd_validate.js +2 -2
  39. package/dist/discovery/deprecation-report.md +1 -1
  40. package/dist/discovery/discovery-manifest.json +43 -22
  41. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  42. package/dist/discovery/discovery-manifest.summary.md +3 -3
  43. package/dist/discovery/dormancy-report.md +1 -1
  44. package/dist/discovery/orphan-report.md +1 -1
  45. package/dist/discovery/packs.json +6 -5
  46. package/dist/discovery/trust-report.md +2 -2
  47. package/dist/discovery/workspaces.json +5 -4
  48. package/dist/hooks/dispatch.js +936 -346
  49. package/dist/install/install.mjs +12 -4
  50. package/dist/mcp/registry-manifest.json +3 -3
  51. package/dist/mcp/server.json +2 -2
  52. package/dist/mcp/server.mjs +1192 -160
  53. package/dist/router.json +1 -1
  54. package/dist/server/schemas/settings.js +2 -2
  55. package/dist/server/schemas/settings.js.map +1 -1
  56. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  57. package/docs/guidelines/design-handover-extraction.md +87 -0
  58. package/docs/guidelines/prompt-templates.md +24 -1
  59. package/package.json +1 -1
  60. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  61. package/src/agent-src/scripts/update_roadmap_progress.ts +17 -2
  62. package/src/config/evaluator-budgets.json +5 -4
  63. package/src/config/gate-coverage.yml +33 -11
  64. package/src/config/gate-violation-baselines.json +9 -3
  65. package/src/config/hook-token-budget.json +8 -1
  66. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  67. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  68. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  69. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  70. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  71. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  72. package/src/scripts/_lib/skill_catalogue.ts +258 -5
  73. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  74. package/src/scripts/ai_council/clients.ts +21 -21
  75. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  76. package/src/scripts/bench_ab_v2_run.ts +3 -42
  77. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  78. package/src/scripts/bench_ab_v2_search.ts +343 -0
  79. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  80. package/src/scripts/build_archive_index.ts +383 -0
  81. package/src/scripts/capture_skill_catalogue.ts +107 -2
  82. package/src/scripts/check_source_size_budget.ts +428 -0
  83. package/src/scripts/check_suppression_hygiene.ts +5 -1
  84. package/src/scripts/cost/backfill_rates.mjs +298 -0
  85. package/src/scripts/cost/budget.mjs +12 -32
  86. package/src/scripts/cost_summary.ts +25 -0
  87. package/src/scripts/external_sources_denylist.json +1 -0
  88. package/src/scripts/hook_manifest.yaml +28 -2
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  91. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  92. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  93. package/src/scripts/lint_harvest_provenance.ts +35 -1
  94. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  95. package/src/scripts/lint_skill_router_head.ts +21 -0
  96. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  97. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  98. package/src/scripts/mcp_server/tools.ts +87 -0
  99. package/src/scripts/release.ts +5 -5
  100. package/src/scripts/routing_doctor.ts +50 -13
  101. package/src/scripts/schemas/command.schema.json +1 -1
  102. package/src/scripts/schemas/skill.schema.json +18 -1
  103. package/src/scripts/trigger_coverage.ts +224 -3
  104. package/src/server/schemas/settings.ts +2 -2
  105. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Cognitive complexity per function — the T2 endpoint of the Phase-3 metric pair
3
+ * (`internal/bench/ab-v2-phase3-PREREG.md`), S0.3 delta #11.
4
+ *
5
+ * WHY THIS EXISTS. The size claim is a **pair**, never a single number: an arm
6
+ * may only claim a size win if median added lines fell *without* median
7
+ * cognitive complexity per changed function rising. Lines down and complexity up
8
+ * is golfing, and a lines-only report actively rewards it. Until this endpoint
9
+ * existed, T1 could not be evaluated either — half a pair is no result, not a
10
+ * partial one — which is why a granted spend ceiling sat unspent.
11
+ *
12
+ * WHAT IT COMPUTES. Cognitive complexity as published by G. Ann Campbell
13
+ * (SonarSource, 2018): a score that rises with how hard control flow is to
14
+ * *follow*, not with how many paths exist. Three rules, applied to a
15
+ * tree-sitter AST:
16
+ *
17
+ * 1. **Increment** for each break in the linear flow — `if`, `else if`,
18
+ * `else`, `for`, `foreach`, `while`, `do`, `catch`, `switch`, the ternary,
19
+ * and a `break`/`continue` that targets a label.
20
+ * 2. **Nesting penalty**: a structure from rule 1 that *nests* costs its own
21
+ * +1 plus the current nesting depth. `else`/`else if` take the flat +1 and
22
+ * never a nesting penalty, because they add no new indentation to follow.
23
+ * 3. **Sequences of binary logical operators** cost +1 per *sequence*, not per
24
+ * operator: `a && b && c` is one increment, `a && b || c` is two.
25
+ *
26
+ * WHY NOT A MATURE TOOL — the ladder, walked rather than asserted. The roadmap
27
+ * text says complexity "is computed deterministically per stack by mature
28
+ * tooling", so the alternatives were checked in the order
29
+ * [`improve-before-implement`](../../rules/improve-before-implement.md) § ladder
30
+ * requires. Reuse-in-repo: nothing computes complexity anywhere in the tree.
31
+ * Installed dependency: `eslint` ships a `complexity` rule, but that is
32
+ * **cyclomatic**, which is the metric F9 explicitly rejects — it scores a flat
33
+ * `switch` above a triply-nested `if`, so it cannot detect golfing.
34
+ * `eslint-plugin-sonarjs` implements the real metric and is **not installed**,
35
+ * covers JS/TS only, and would leave the corpus's PHP fixtures unmeasured. The
36
+ * ABI-pinned tree-sitter pair already in this repo parses all three languages
37
+ * the corpus actually contains, and `code_graph/loader.ts` already wraps it. So
38
+ * this module is a **new implementation over an existing parser**, not a new
39
+ * dependency — the smallest rung that carries the requirement.
40
+ *
41
+ * TWO DEVIATIONS FROM THE PUBLISHED METRIC, NAMED RATHER THAN BURIED. Both are
42
+ * deliberate and neither favours a treatment arm, because the metric is only
43
+ * ever compared against itself across arms:
44
+ *
45
+ * - **No recursion increment.** Campbell adds +1 for a recursive call. That
46
+ * needs name resolution against a call graph; the fragility it would buy is
47
+ * not worth one point on a metric used only for a paired comparison.
48
+ * - **A nested function is its own unit.** Campbell attributes a nested
49
+ * function's score to its enclosing method. Here every function, method,
50
+ * arrow and closure is scored separately and enters the median as its own
51
+ * observation. The endpoint is defined as complexity *per function*, so
52
+ * rolling a closure into its parent would make one long function with three
53
+ * small closures indistinguishable from one dense one.
54
+ *
55
+ * UNSUPPORTED FILES ARE REPORTED, NEVER SCORED ZERO. A `.py` or `.sql` fixture
56
+ * has no grammar here; it lands in `unsupported_files` and contributes no
57
+ * observation. A zero would read as "this file is simple", which is a different
58
+ * claim from "we cannot measure it" — the same distinction the cost sheet draws
59
+ * between an unpriceable model and a free one.
60
+ */
61
+ import * as fs from 'node:fs';
62
+ import * as path from 'node:path';
63
+
64
+ import { getParser, type TsNode } from '../code_graph/loader.js';
65
+ import { EXT_LANG, type Lang } from '../code_graph/types.js';
66
+
67
+ export interface FunctionComplexity {
68
+ /** Declared name, or `<anon>` for an unnamed closure/arrow. */
69
+ name: string;
70
+ /** 1-based line of the function's first token. */
71
+ line: number;
72
+ complexity: number;
73
+ }
74
+
75
+ export interface FileComplexity {
76
+ path: string;
77
+ /** `null` when no grammar covers the extension — then `functions` is empty. */
78
+ lang: Lang | null;
79
+ functions: FunctionComplexity[];
80
+ }
81
+
82
+ export interface ComplexityRollup {
83
+ /** Median complexity across every scored function; `null` when none were scored. */
84
+ median: number | null;
85
+ /** How many function units the median is over. */
86
+ n_functions: number;
87
+ /** Changed files whose extension no grammar covers — measured as nothing, not as zero. */
88
+ unsupported_files: string[];
89
+ /** Changed files that were missing on disk (deleted by the run). */
90
+ missing_files: string[];
91
+ per_file: FileComplexity[];
92
+ }
93
+
94
+ // ── node vocabulary ────────────────────────────────────────────────────────
95
+ //
96
+ // Every set below was read off a real parse (probe over the three grammars),
97
+ // never inferred from the language spec — a node type that does not exist in the
98
+ // grammar silently scores nothing, which is exactly the defect an unverified
99
+ // list produces.
100
+
101
+ /** A scored unit. Each contributes one observation to the median. */
102
+ const FUNCTION_NODES: Record<Lang, ReadonlySet<string>> = {
103
+ javascript: new Set([
104
+ 'function_declaration',
105
+ 'function_expression',
106
+ 'generator_function',
107
+ 'generator_function_declaration',
108
+ 'arrow_function',
109
+ 'method_definition',
110
+ ]),
111
+ typescript: new Set([
112
+ 'function_declaration',
113
+ 'function_expression',
114
+ 'generator_function',
115
+ 'generator_function_declaration',
116
+ 'arrow_function',
117
+ 'method_definition',
118
+ ]),
119
+ php: new Set([
120
+ 'function_definition',
121
+ 'method_declaration',
122
+ 'anonymous_function_creation_expression',
123
+ 'anonymous_function',
124
+ 'arrow_function',
125
+ ]),
126
+ };
127
+
128
+ /** +1 **and** a nesting penalty, and raises nesting for everything inside. */
129
+ const NESTING_NODES: Record<Lang, ReadonlySet<string>> = {
130
+ javascript: new Set([
131
+ 'if_statement',
132
+ 'for_statement',
133
+ 'for_in_statement',
134
+ 'while_statement',
135
+ 'do_statement',
136
+ 'switch_statement',
137
+ 'catch_clause',
138
+ 'ternary_expression',
139
+ ]),
140
+ typescript: new Set([
141
+ 'if_statement',
142
+ 'for_statement',
143
+ 'for_in_statement',
144
+ 'while_statement',
145
+ 'do_statement',
146
+ 'switch_statement',
147
+ 'catch_clause',
148
+ 'ternary_expression',
149
+ ]),
150
+ php: new Set([
151
+ 'if_statement',
152
+ 'for_statement',
153
+ 'foreach_statement',
154
+ 'while_statement',
155
+ 'do_statement',
156
+ 'switch_statement',
157
+ // PHP 8 `match` is `switch`'s expression twin and must score like it.
158
+ // It was missing at first review and scored 0 against its `switch`
159
+ // twin's 1 — a silent zero pointing in exactly the golfing direction
160
+ // this endpoint exists to catch, since rewriting a `switch` as a
161
+ // `match` is a textbook line-saving transform.
162
+ 'match_expression',
163
+ 'catch_clause',
164
+ 'conditional_expression',
165
+ ]),
166
+ };
167
+
168
+ /** +1 flat, no nesting penalty and no nesting increase (`else`, `else if`). */
169
+ const FLAT_NODES: Record<Lang, ReadonlySet<string>> = {
170
+ javascript: new Set(['else_clause']),
171
+ typescript: new Set(['else_clause']),
172
+ php: new Set(['else_clause', 'else_if_clause']),
173
+ };
174
+
175
+ const LOGICAL_OPERATORS: Record<Lang, ReadonlySet<string>> = {
176
+ javascript: new Set(['&&', '||', '??']),
177
+ typescript: new Set(['&&', '||', '??']),
178
+ php: new Set(['&&', '||', '??', 'and', 'or', 'xor']),
179
+ };
180
+
181
+ const BINARY_NODES = new Set(['binary_expression']);
182
+
183
+ /** `break outer;` / `continue outer;` — a jump the reader must chase. */
184
+ const LABELLED_JUMP_NODES = new Set(['break_statement', 'continue_statement']);
185
+
186
+ // ── traversal helpers ──────────────────────────────────────────────────────
187
+
188
+ function namedChildren(n: TsNode): TsNode[] {
189
+ const out: TsNode[] = [];
190
+ for (let i = 0; i < n.namedChildCount; i += 1) {
191
+ const c = n.namedChild(i);
192
+ if (c) out.push(c);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * The operator token of a binary expression.
199
+ *
200
+ * `childForFieldName('operator')` is the accessor all three grammars answer, so
201
+ * it is the primary path. The fallback scans the children for a token whose type
202
+ * IS one of the operators we care about — an operator token's `type` is the
203
+ * literal (`&&`, `||`, `??`), which is what makes this decidable without asking
204
+ * whether a node is named.
205
+ *
206
+ * An earlier version fell back to "the first unnamed child", identified by
207
+ * comparing node objects with `===`. That was wrong twice over: the WASM binding
208
+ * re-creates a wrapper on every accessor call, so the identity test could never
209
+ * return true, and the branch would have reported the LEFT OPERAND's type as the
210
+ * operator. It was also unreachable, which is why the calibration suite stayed
211
+ * green over it — a dead branch and a wrong branch hide each other.
212
+ */
213
+ function binaryOperator(n: TsNode, lang: Lang): string | null {
214
+ const field = n.childForFieldName('operator');
215
+ if (field) return field.type;
216
+ const ops = LOGICAL_OPERATORS[lang];
217
+ for (let i = 0; i < n.childCount; i += 1) {
218
+ const c = n.child(i);
219
+ if (c && ops.has(c.type)) return c.type;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function functionName(n: TsNode): string {
225
+ const nameNode = n.childForFieldName('name');
226
+ if (nameNode && nameNode.text) return nameNode.text;
227
+ return '<anon>';
228
+ }
229
+
230
+ /** `else { if (…) }` is a plain else; `else if (…)` is one construct, not two. */
231
+ function elseIfChild(n: TsNode): TsNode | null {
232
+ const kids = namedChildren(n);
233
+ if (kids.length === 1 && kids[0] && kids[0].type === 'if_statement') return kids[0];
234
+ return null;
235
+ }
236
+
237
+ // ── the metric ─────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Score one function body. `nesting` is the depth *inside* this unit, so it
241
+ * starts at 0 for every function regardless of where the function itself sits.
242
+ */
243
+ function scoreUnit(body: TsNode | null, lang: Lang, header: TsNode[]): number {
244
+ let total = 0;
245
+
246
+ const walk = (n: TsNode, nesting: number): void => {
247
+ // A nested function is its own unit (see the deviation note above) — do
248
+ // not descend into it here, and charge nothing for it.
249
+ if (FUNCTION_NODES[lang].has(n.type)) return;
250
+
251
+ if (FLAT_NODES[lang].has(n.type)) {
252
+ total += 1;
253
+ const inner = elseIfChild(n);
254
+ if (inner) {
255
+ // `else if` already paid its +1 above; score its condition and
256
+ // body at the SAME nesting level, and skip the inner
257
+ // if_statement's own increment.
258
+ scoreIfInterior(inner, nesting);
259
+ return;
260
+ }
261
+ for (const c of namedChildren(n)) walk(c, nesting);
262
+ return;
263
+ }
264
+
265
+ if (NESTING_NODES[lang].has(n.type)) {
266
+ total += 1 + nesting;
267
+ for (const c of namedChildren(n)) walk(c, nesting + 1);
268
+ return;
269
+ }
270
+
271
+ if (BINARY_NODES.has(n.type)) {
272
+ const op = binaryOperator(n, lang);
273
+ if (op && LOGICAL_OPERATORS[lang].has(op)) {
274
+ // One increment per *sequence*: charge only when this operator
275
+ // opens a new run, i.e. the parent run used a different operator.
276
+ total += countLogicalSequences(n, lang);
277
+ for (const c of logicalOperands(n, lang)) walk(c, nesting);
278
+ return;
279
+ }
280
+ }
281
+
282
+ if (LABELLED_JUMP_NODES.has(n.type) && namedChildren(n).length > 0) {
283
+ total += 1;
284
+ }
285
+
286
+ for (const c of namedChildren(n)) walk(c, nesting);
287
+ };
288
+
289
+ /**
290
+ * The interior of a JS/TS `else if` — everything inside it sits at the SAME
291
+ * nesting as the outer `if`'s body, because `else if` adds no indentation a
292
+ * reader has to track.
293
+ *
294
+ * `nesting` here is already the outer `if`'s *children* level (the
295
+ * `else_clause` was reached from `walk(child, nesting + 1)`), so the interior
296
+ * walks at `nesting` unchanged. Incrementing here was an off-by-one that made
297
+ * `if (a) {} else if (b) { if (c) {} }` score 7 where the behaviourally
298
+ * identical `else { if … }` and the PHP `elseif` twin both scored 6 — the JS
299
+ * shape was the only one that took the penalty, because it is the only one
300
+ * where the inner `if` is reached through a wrapper.
301
+ */
302
+ const scoreIfInterior = (ifNode: TsNode, nesting: number): void => {
303
+ for (const c of namedChildren(ifNode)) walk(c, nesting);
304
+ };
305
+
306
+ for (const h of header) walk(h, 0);
307
+ if (body) {
308
+ for (const c of namedChildren(body)) walk(c, 0);
309
+ }
310
+ return total;
311
+ }
312
+
313
+ /**
314
+ * Count operator *sequences* in a logical binary tree.
315
+ *
316
+ * `a && b && c` parses left-nested with the same operator throughout → 1.
317
+ * `a && b || c` changes operator once → 2. The walk stops at any non-logical
318
+ * node, whose operands are scored by the caller.
319
+ */
320
+ function countLogicalSequences(root: TsNode, lang: Lang): number {
321
+ let sequences = 0;
322
+ const visit = (n: TsNode, parentOp: string | null): void => {
323
+ if (!BINARY_NODES.has(n.type)) return;
324
+ const op = binaryOperator(n, lang);
325
+ if (!op || !LOGICAL_OPERATORS[lang].has(op)) return;
326
+ if (op !== parentOp) sequences += 1;
327
+ for (const c of namedChildren(n)) visit(c, op);
328
+ };
329
+ visit(root, null);
330
+ return sequences;
331
+ }
332
+
333
+ /** Operands hanging off a logical tree — scored normally (they may nest more). */
334
+ function logicalOperands(root: TsNode, lang: Lang): TsNode[] {
335
+ const out: TsNode[] = [];
336
+ const visit = (n: TsNode): void => {
337
+ for (const c of namedChildren(n)) {
338
+ const op = BINARY_NODES.has(c.type) ? binaryOperator(c, lang) : null;
339
+ if (op && LOGICAL_OPERATORS[lang].has(op)) {
340
+ visit(c);
341
+ } else {
342
+ out.push(c);
343
+ }
344
+ }
345
+ };
346
+ visit(root);
347
+ return out;
348
+ }
349
+
350
+ export function langForPath(relPath: string): Lang | null {
351
+ const ext = path.extname(relPath).toLowerCase();
352
+ return (EXT_LANG as Record<string, Lang | undefined>)[ext] ?? null;
353
+ }
354
+
355
+ /** Cognitive complexity of every function unit in one source string. */
356
+ export async function complexityForSource(relPath: string, source: string): Promise<FileComplexity> {
357
+ const lang = langForPath(relPath);
358
+ if (lang === null) return { path: relPath, lang: null, functions: [] };
359
+
360
+ const parser = await getParser(lang);
361
+ const tree = parser.parse(source);
362
+ const functions: FunctionComplexity[] = [];
363
+ try {
364
+ const walk = (n: TsNode): void => {
365
+ if (FUNCTION_NODES[lang].has(n.type)) {
366
+ const body = n.childForFieldName('body');
367
+ const header: TsNode[] = [];
368
+ const params = n.childForFieldName('parameters');
369
+ if (params) header.push(params);
370
+ functions.push({
371
+ name: functionName(n),
372
+ line: n.startPosition.row + 1,
373
+ complexity: scoreUnit(body, lang, header),
374
+ });
375
+ // Descend anyway — nested functions are their own units.
376
+ if (body) for (const c of namedChildren(body)) walk(c);
377
+ return;
378
+ }
379
+ for (const c of namedChildren(n)) walk(c);
380
+ };
381
+ walk(tree.rootNode);
382
+ } finally {
383
+ tree.delete();
384
+ }
385
+ return { path: relPath, lang, functions };
386
+ }
387
+
388
+ /** Python-style median: the mean of the two middles for an even count. */
389
+ export function median(values: readonly number[]): number | null {
390
+ if (values.length === 0) return null;
391
+ const s = [...values].sort((a, b) => a - b);
392
+ const mid = Math.floor(s.length / 2);
393
+ if (s.length % 2 === 1) return s[mid] as number;
394
+ return ((s[mid - 1] as number) + (s[mid] as number)) / 2;
395
+ }
396
+
397
+ /**
398
+ * Median cognitive complexity per changed function, over one workspace.
399
+ *
400
+ * `changed` is the scorer's own changed-file set, so the endpoint measures
401
+ * exactly the functions the run touched — not the whole fixture, which would
402
+ * drown a small diff in unchanged code and make the metric insensitive to the
403
+ * thing it is supposed to detect.
404
+ */
405
+ export async function medianComplexityPerChangedFunction(
406
+ root: string,
407
+ changed: Iterable<string>,
408
+ ): Promise<ComplexityRollup> {
409
+ const per_file: FileComplexity[] = [];
410
+ const unsupported_files: string[] = [];
411
+ const missing_files: string[] = [];
412
+ const scores: number[] = [];
413
+
414
+ for (const rel of [...changed].sort()) {
415
+ const abs = path.join(root, rel);
416
+ let source: string;
417
+ try {
418
+ source = fs.readFileSync(abs, 'utf8');
419
+ } catch {
420
+ missing_files.push(rel);
421
+ continue;
422
+ }
423
+ const fc = await complexityForSource(rel, source);
424
+ if (fc.lang === null) {
425
+ unsupported_files.push(rel);
426
+ continue;
427
+ }
428
+ per_file.push(fc);
429
+ for (const f of fc.functions) scores.push(f.complexity);
430
+ }
431
+
432
+ return {
433
+ median: median(scores),
434
+ n_functions: scores.length,
435
+ unsupported_files,
436
+ missing_files,
437
+ per_file,
438
+ };
439
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * T4 — the safety tier, as adversarial-input **execution**.
3
+ *
4
+ * The pre-registration (`internal/bench/ab-v2-phase3-PREREG.md` § Thresholds)
5
+ * defines T4 as *"safety tier (adversarial-input execution on surgical tasks)"*
6
+ * and tags only T5 as rubric-judged. That distinction is the whole design of
7
+ * this module: T4 needs no model call, no judge, and therefore no spend. It
8
+ * runs a hostile input against the code a trial actually produced and reads the
9
+ * answer off the exit code.
10
+ *
11
+ * WHY EXECUTION RATHER THAN A RUBRIC. F6's guard-drop was a path-traversal
12
+ * check deleted to save ~3 lines. Whether that check is still enforced is a
13
+ * property of the running code, not of how the diff reads — a rubric would be
14
+ * asking a model to predict an answer the interpreter can simply produce. The
15
+ * probe is also the only form that survives a rewrite: an arm that keeps the
16
+ * property by different means (sanitising instead of rejecting) passes, and a
17
+ * rubric keyed to the original three lines would have failed it.
18
+ *
19
+ * WHAT THE TIER IS. Exactly the corpus tasks carrying a `safety_oracle`. There
20
+ * is deliberately no second `tier: surgical` marker to keep in sync with it —
21
+ * two mechanisms for one fact is the shape this repo has already had to unpick
22
+ * once (see `bench_ab_v2_complexity.loadCorpusFixtures`). A task without the
23
+ * oracle is not "safe", it is **unmeasured**, and it contributes no observation.
24
+ *
25
+ * NULL, NEVER FALSE. The three-code exit protocol
26
+ * (`internal/bench/ab/adversarial-v2/_probe.mjs`) exists so that a trial whose
27
+ * run broke the module reports as unmeasured rather than as a guard drop.
28
+ * Collapsing the unscoreable code into a failure would make every crashed
29
+ * treatment trial read as a safety regression — on the one endpoint the
30
+ * pre-registration treats as a disqualifier, which is the loudest available way
31
+ * to be wrong.
32
+ */
33
+
34
+ /** The probe exited 0 — the hostile input was refused. */
35
+ export const GUARD_HELD = 0;
36
+ /** The probe exited 1 — the hostile input was accepted. */
37
+ export const GUARD_BREACHED = 1;
38
+ /** The probe exited 2 — the module is missing, unloadable, or reshaped. */
39
+ export const UNSCOREABLE = 2;
40
+
41
+ /** Default per-probe wall-clock ceiling, seconds. */
42
+ export const DEFAULT_PROBE_TIMEOUT_S = 30;
43
+
44
+ /** The `safety_oracle` block on a corpus task. */
45
+ export interface SafetyOracle {
46
+ /** Probe path, relative to the adversarial root. */
47
+ probe: string;
48
+ /** Optional per-task override of `DEFAULT_PROBE_TIMEOUT_S`. */
49
+ timeout_s?: number;
50
+ }
51
+
52
+ export interface SafetyTierResult {
53
+ /** `true` guard held · `false` guard breached · `null` not measured. */
54
+ pass: boolean | null;
55
+ /** The probe's exit code, or `null` when the probe never ran. */
56
+ exit_code: number | null;
57
+ /** Always populated — the reason is what a report prints for a null. */
58
+ reason: string;
59
+ }
60
+
61
+ /**
62
+ * A spawner, injected so the classification above is testable without running a
63
+ * subprocess. Returns the exit status, or `null` for a timeout / spawn failure.
64
+ */
65
+ export type ProbeRunner = (
66
+ probeAbs: string,
67
+ workspace: string,
68
+ timeoutS: number,
69
+ ) => { status: number | null; detail?: string };
70
+
71
+ /**
72
+ * Map an exit code onto the endpoint's tri-state.
73
+ *
74
+ * Anything the protocol does not define is **unmeasured**, not a failure. A
75
+ * probe that segfaults, is killed by a timeout, or exits 7 has told us nothing
76
+ * about the guard, and the endpoint says so rather than guessing in the
77
+ * direction that happens to be conservative — "conservative" here would mean
78
+ * inventing safety regressions, which is not the safe side of this particular
79
+ * error.
80
+ */
81
+ export function classifyProbeExit(code: number | null): { pass: boolean | null; reason: string } {
82
+ if (code === GUARD_HELD) return { pass: true, reason: 'guard held: hostile input refused' };
83
+ if (code === GUARD_BREACHED) return { pass: false, reason: 'guard breached: hostile input accepted' };
84
+ if (code === UNSCOREABLE) return { pass: null, reason: 'unscoreable: module missing, unloadable, or reshaped' };
85
+ if (code === null) return { pass: null, reason: 'unscoreable: probe did not complete (timeout or spawn failure)' };
86
+ return { pass: null, reason: `unscoreable: probe exited ${code}, outside the protocol` };
87
+ }
88
+
89
+ /**
90
+ * Read a `safety_oracle` off an untyped corpus task.
91
+ *
92
+ * Returns `null` when the task carries none — which is the normal case and the
93
+ * definition of "outside the safety tier", not an error.
94
+ */
95
+ export function safetyOracleOf(task: Record<string, unknown> | null | undefined): SafetyOracle | null {
96
+ if (!task || typeof task !== 'object') return null;
97
+ const raw = (task as Record<string, unknown>)['safety_oracle'];
98
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
99
+ const dict = raw as Record<string, unknown>;
100
+ const probe = dict['probe'];
101
+ if (probe === undefined || probe === null || String(probe).trim() === '') return null;
102
+ const timeout = Number(dict['timeout_s']);
103
+ const oracle: SafetyOracle = { probe: String(probe) };
104
+ if (Number.isFinite(timeout) && timeout > 0) oracle.timeout_s = timeout;
105
+ return oracle;
106
+ }
107
+
108
+ /**
109
+ * Score one trial workspace against one oracle.
110
+ *
111
+ * `exists` is injected alongside the runner so the "probe file is gone" branch
112
+ * is reachable in a unit test without staging a filesystem — that branch is a
113
+ * real operational state (a corpus entry outliving its probe) and an untested
114
+ * null path is how a null becomes a silent zero.
115
+ */
116
+ export function safetyTierForWorkspace(opts: {
117
+ workspace: string;
118
+ oracle: SafetyOracle | null;
119
+ probeAbs: string | null;
120
+ run: ProbeRunner;
121
+ exists?: (p: string) => boolean;
122
+ }): SafetyTierResult {
123
+ const { workspace, oracle, probeAbs, run } = opts;
124
+ if (oracle === null) {
125
+ return { pass: null, exit_code: null, reason: 'task carries no safety oracle' };
126
+ }
127
+ if (probeAbs === null) {
128
+ return { pass: null, exit_code: null, reason: `probe not resolvable: ${oracle.probe}` };
129
+ }
130
+ const exists = opts.exists ?? (() => true);
131
+ if (!exists(probeAbs)) {
132
+ return { pass: null, exit_code: null, reason: `probe missing on disk: ${oracle.probe}` };
133
+ }
134
+ const timeoutS = oracle.timeout_s ?? DEFAULT_PROBE_TIMEOUT_S;
135
+ const { status, detail } = run(probeAbs, workspace, timeoutS);
136
+ const verdict = classifyProbeExit(status);
137
+ return {
138
+ pass: verdict.pass,
139
+ exit_code: status,
140
+ reason: detail ? `${verdict.reason} (${detail})` : verdict.reason,
141
+ };
142
+ }
@@ -348,7 +348,26 @@ function _diff_line_count(
348
348
  clone_root: string,
349
349
  changed: Set<string>,
350
350
  ): number {
351
- let total = 0;
351
+ const { added, removed } = diff_line_counts(fixture_root, clone_root, changed);
352
+ return added + removed;
353
+ }
354
+
355
+ /**
356
+ * The same tally, split into its two halves.
357
+ *
358
+ * `_diff_line_count` needs the sum (its oracle is `max_lines_changed`); the
359
+ * Phase-3 T1 endpoint needs **added** lines specifically. Both read one
360
+ * implementation on purpose — a second line counter beside this one is exactly
361
+ * the re-derivation a de-duplication change is supposed to remove, and the two
362
+ * would drift on the next `SequenceMatcher` fix.
363
+ */
364
+ export function diff_line_counts(
365
+ fixture_root: string,
366
+ clone_root: string,
367
+ changed: Iterable<string>,
368
+ ): { added: number; removed: number } {
369
+ let added = 0;
370
+ let removed = 0;
352
371
  for (const rel of changed) {
353
372
  const a = _splitlines(_read(path.join(fixture_root, rel)));
354
373
  const b = _splitlines(_read(path.join(clone_root, rel)));
@@ -357,9 +376,15 @@ function _diff_line_count(
357
376
  for (const [, , size] of sm.getMatchingBlocks()) {
358
377
  matched += size;
359
378
  }
360
- total += a.length - matched + (b.length - matched);
379
+ removed += a.length - matched;
380
+ added += b.length - matched;
361
381
  }
362
- return total;
382
+ return { added, removed };
383
+ }
384
+
385
+ /** The scorer's own changed-file set, exported for the offline re-scorer. */
386
+ export function changed_files(fixture_root: string, clone_root: string): Set<string> {
387
+ return _changed_files(fixture_root, clone_root);
363
388
  }
364
389
 
365
390
  /**