@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,252 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Offline re-scorer for the Phase-3 metric pair — S0.3 delta #11.
4
+ *
5
+ * Reads a finished `ab-v2` report, walks every trial's **preserved workspace**
6
+ * (delta #7 keyed `task|arm|seed`), and writes two endpoints into each trial's
7
+ * `metrics`:
8
+ *
9
+ * - `added_lines` — T1's endpoint. Added only, not added+removed: the
10
+ * discipline oracle's `max_lines_changed` wants the sum, the size claim
11
+ * wants what the run *wrote*.
12
+ * - `median_cognitive_complexity` — T2's endpoint, over the functions in the
13
+ * files that trial actually changed.
14
+ *
15
+ * WHY OFFLINE RATHER THAN IN THE RUNNER. Three reasons, in order of weight.
16
+ * The metric is **retro-fittable**: the workspaces from every completed sweep
17
+ * are on disk, so a run that already cost money can gain the endpoint without
18
+ * being re-run — which is what makes the anti-golfing gate nearly free, and what
19
+ * the roadmap's own text promises. It is **deterministic and model-free**, so
20
+ * re-scoring the same workspace twice cannot produce two numbers. And the
21
+ * grammar load is **async** while `trajectory_metrics` is synchronous and called
22
+ * from four places — threading async through the live path would be a larger
23
+ * diff than the endpoint itself, for no measurement it does not already get.
24
+ *
25
+ * The report is rewritten in place only with `--write`; the default prints the
26
+ * table and touches nothing, because silently rewriting a pinned artefact is how
27
+ * a report stops matching the numbers already quoted from it.
28
+ *
29
+ * Usage:
30
+ * bench_ab_v2_complexity.ts <report.json> [--write] [--json]
31
+ */
32
+ import * as fs from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ import { parse as parseYaml } from 'yaml';
37
+
38
+ import { medianComplexityPerChangedFunction } from './_lib/bench_ab_complexity.js';
39
+ import { changed_files, diff_line_counts } from './_lib/bench_ab_scoring_v2.js';
40
+
41
+ const _HERE = fileURLToPath(import.meta.url);
42
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
43
+ const FIXTURES_ROOT = path.join(REPO_ROOT, 'internal', 'bench', 'ab');
44
+ const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-trackb-v2.yaml');
45
+
46
+ type Dict = Record<string, unknown>;
47
+
48
+ function _dictOr(v: unknown): Dict {
49
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Dict) : {};
50
+ }
51
+
52
+ /**
53
+ * task id → fixture path. The corpus is the ONLY resolver, deliberately.
54
+ *
55
+ * An earlier draft also had the runner stamp a `fixture` key onto each record so
56
+ * reports would be self-describing, and this function read it first. That is two
57
+ * mechanisms for one fact: the corpus lookup already covers every report,
58
+ * including every one written before the key would have existed. The extra field
59
+ * was dropped rather than kept as a fallback — a reader with no producer is the
60
+ * shape this branch's own completion review flagged elsewhere.
61
+ *
62
+ * A missing or unreadable corpus is not an error: it degrades every affected
63
+ * trial to `skipped_reason: 'task id not in the corpus'`, which is a reportable
64
+ * state, rather than aborting a re-score that may still cover most of the report.
65
+ */
66
+ function loadCorpusFixtures(corpusPath: string | null): Map<string, string> {
67
+ const byId = new Map<string, string>();
68
+ if (!corpusPath || !fs.existsSync(corpusPath)) return byId;
69
+ try {
70
+ const corpus = _dictOr(parseYaml(fs.readFileSync(corpusPath, 'utf8'), { version: '1.1' }));
71
+ const tasks = Array.isArray(corpus['tasks']) ? (corpus['tasks'] as Dict[]) : [];
72
+ for (const t of tasks) {
73
+ if (t['id'] !== undefined && t['fixture'] !== undefined) {
74
+ byId.set(String(t['id']), String(t['fixture']));
75
+ }
76
+ }
77
+ } catch {
78
+ return byId;
79
+ }
80
+ return byId;
81
+ }
82
+
83
+ export interface TrialRescore {
84
+ task: string;
85
+ arm: string;
86
+ seed: unknown;
87
+ /** `null` when the workspace or the fixture is no longer on disk. */
88
+ added_lines: number | null;
89
+ median_cognitive_complexity: number | null;
90
+ n_functions: number;
91
+ unsupported_files: string[];
92
+ skipped_reason: string | null;
93
+ }
94
+
95
+ /**
96
+ * Re-score one report payload. Pure apart from the filesystem reads it needs;
97
+ * mutates `payload` only when `write` is true. The fixture each trial is diffed
98
+ * against is resolved from the corpus by task id — see `loadCorpusFixtures`.
99
+ */
100
+ export async function rescoreReport(
101
+ payload: Dict,
102
+ opts: { fixturesRoot?: string; write?: boolean; corpusPath?: string | null } = {},
103
+ ): Promise<TrialRescore[]> {
104
+ const fixturesRoot = opts.fixturesRoot ?? FIXTURES_ROOT;
105
+ let wrote = 0;
106
+ const byId = loadCorpusFixtures(opts.corpusPath === undefined ? CORPUS_PATH : opts.corpusPath);
107
+ const out: TrialRescore[] = [];
108
+ const records = Array.isArray(payload['records']) ? (payload['records'] as Dict[]) : [];
109
+
110
+ for (const rec of records) {
111
+ const taskId = String(rec['id'] ?? '<unknown>');
112
+ const fixtureRel = byId.get(taskId) ?? null;
113
+ const arms = _dictOr(rec['arms']);
114
+ for (const [arm, runsRaw] of Object.entries(arms)) {
115
+ const runs = Array.isArray(runsRaw) ? (runsRaw as Dict[]) : [];
116
+ for (const run of runs) {
117
+ const row: TrialRescore = {
118
+ task: taskId,
119
+ arm,
120
+ seed: run['seed'] ?? null,
121
+ added_lines: null,
122
+ median_cognitive_complexity: null,
123
+ n_functions: 0,
124
+ unsupported_files: [],
125
+ skipped_reason: null,
126
+ };
127
+
128
+ const workspace = run['workspace'] !== undefined ? String(run['workspace']) : '';
129
+ if (!workspace || !fs.existsSync(workspace)) {
130
+ // Not a zero — a trial whose workspace was pruned is
131
+ // unmeasurable, and `compare()` reads a missing metric as
132
+ // "not measured on this pair" rather than as a value.
133
+ row.skipped_reason = workspace ? 'workspace missing on disk' : 'no workspace recorded';
134
+ out.push(row);
135
+ continue;
136
+ }
137
+ if (fixtureRel === null) {
138
+ row.skipped_reason = 'task id not in the corpus';
139
+ out.push(row);
140
+ continue;
141
+ }
142
+ const fixture = path.join(fixturesRoot, fixtureRel);
143
+ if (!fs.existsSync(fixture)) {
144
+ row.skipped_reason = 'fixture missing on disk';
145
+ out.push(row);
146
+ continue;
147
+ }
148
+
149
+ const changed = changed_files(fixture, workspace);
150
+ row.added_lines = diff_line_counts(fixture, workspace, changed).added;
151
+ const rollup = await medianComplexityPerChangedFunction(workspace, changed);
152
+ row.median_cognitive_complexity = rollup.median;
153
+ row.n_functions = rollup.n_functions;
154
+ row.unsupported_files = rollup.unsupported_files;
155
+
156
+ if (opts.write) {
157
+ const metrics = _dictOr(run['metrics']);
158
+ metrics['added_lines'] = row.added_lines;
159
+ // `median` is null when the diff contained no scorable
160
+ // function, and null is written on purpose: `compare()` reads
161
+ // a null as "not measured on this pair", while a 0 would claim
162
+ // the run produced maximally simple code. The count sits
163
+ // beside it so a reader can tell "nothing to score" from
164
+ // "scored, and it was trivial".
165
+ metrics['median_cognitive_complexity'] = row.median_cognitive_complexity;
166
+ metrics['complexity_n_functions'] = row.n_functions;
167
+ run['metrics'] = metrics;
168
+ wrote += 1;
169
+ }
170
+ out.push(row);
171
+ }
172
+ }
173
+ }
174
+ if (opts.write) lastWriteCount = wrote;
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * How many trials the last `rescoreReport({ write: true })` actually touched.
180
+ *
181
+ * The CLI uses it to skip rewriting the report when nothing was measured:
182
+ * re-serialising a pinned artefact for a zero-row pass changes its bytes (and
183
+ * its formatting) without changing a single number, which is exactly the kind of
184
+ * silent churn that makes a report stop matching the figures quoted from it.
185
+ */
186
+ let lastWriteCount = 0;
187
+ export function trialsWrittenByLastRescore(): number {
188
+ return lastWriteCount;
189
+ }
190
+
191
+ function renderTable(rows: TrialRescore[]): string {
192
+ const L: string[] = [];
193
+ L.push('task | arm | seed | added | median cc | fns | note');
194
+ L.push('-----|-----|------|-------|-----------|-----|-----');
195
+ for (const r of rows) {
196
+ L.push(
197
+ [
198
+ r.task,
199
+ r.arm,
200
+ String(r.seed ?? ''),
201
+ r.added_lines === null ? '-' : String(r.added_lines),
202
+ r.median_cognitive_complexity === null ? '-' : String(r.median_cognitive_complexity),
203
+ String(r.n_functions),
204
+ r.skipped_reason ??
205
+ (r.unsupported_files.length > 0
206
+ ? `unsupported: ${r.unsupported_files.join(',')}`
207
+ : ''),
208
+ ].join(' | '),
209
+ );
210
+ }
211
+ const measured = rows.filter((r) => r.median_cognitive_complexity !== null).length;
212
+ L.push('');
213
+ L.push(`${measured}/${rows.length} trials carry a complexity observation.`);
214
+ return L.join('\n');
215
+ }
216
+
217
+ async function main(argv: string[]): Promise<number> {
218
+ const args = argv.filter((a) => !a.startsWith('--'));
219
+ const write = argv.includes('--write');
220
+ const asJson = argv.includes('--json');
221
+ const reportPath = args[0];
222
+ if (!reportPath) {
223
+ process.stderr.write(
224
+ 'usage: bench_ab_v2_complexity.ts <report.json> [--write] [--json]\n',
225
+ );
226
+ return 1;
227
+ }
228
+ if (!fs.existsSync(reportPath)) {
229
+ process.stderr.write(`report not found: ${reportPath}\n`);
230
+ return 1;
231
+ }
232
+ const payload = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as Dict;
233
+ const rows = await rescoreReport(payload, { write });
234
+ const written = trialsWrittenByLastRescore();
235
+ if (write && written > 0) {
236
+ fs.writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
237
+ } else if (write) {
238
+ process.stderr.write('no trial could be measured — report left untouched\n');
239
+ }
240
+ process.stdout.write(asJson ? `${JSON.stringify(rows, null, 2)}\n` : `${renderTable(rows)}\n`);
241
+ return 0;
242
+ }
243
+
244
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
245
+ main(process.argv.slice(2)).then(
246
+ (code) => process.exit(code),
247
+ (err: unknown) => {
248
+ process.stderr.write(`${String(err)}\n`);
249
+ process.exit(1);
250
+ },
251
+ );
252
+ }
@@ -45,7 +45,6 @@
45
45
  import { spawnSync } from 'node:child_process';
46
46
  import { createHash } from 'node:crypto';
47
47
  import * as fs from 'node:fs';
48
- import * as os from 'node:os';
49
48
  import * as path from 'node:path';
50
49
  import process from 'node:process';
51
50
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -53,6 +52,7 @@ import { parse as parseYaml } from 'yaml';
53
52
 
54
53
  import * as v1 from './bench_ab_task_runner.js';
55
54
  import * as scoring from './_lib/bench_ab_scoring_v2.js';
55
+ import { preserve_transcript, reset_fixture } from './_lib/bench_ab_workspace.js';
56
56
  import {
57
57
  SweepBudget,
58
58
  activation_verdict,
@@ -75,7 +75,6 @@ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
75
75
  const ROUTER_PATH = path.join(REPO_ROOT, 'dist', 'router.json');
76
76
  const RULES_DIR = path.join(REPO_ROOT, 'dist', 'agent-src', 'rules');
77
77
  const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-trackb-v2.yaml');
78
- const FIXTURES_ROOT = path.join(REPO_ROOT, 'internal', 'bench', 'ab');
79
78
  const REPORTS_DIR = path.join(REPO_ROOT, 'internal', 'bench', 'reports', 'ab-v2');
80
79
  const CHECKPOINTS_DIR = path.join(REPORTS_DIR, 'checkpoints');
81
80
  const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
@@ -87,7 +86,6 @@ const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
87
86
  // vanilla ≈ package and invalidated every prior null. A /tmp clone has no
88
87
  // agent-config ancestor → vanilla is truly plain; `package` still activates via
89
88
  // the USER-scope global plugin regardless of cwd.
90
- const WORK_ROOT = path.join(os.tmpdir(), 'agent-config-bench-v2-clones');
91
89
 
92
90
  type Dict = Record<string, unknown>;
93
91
 
@@ -329,45 +327,6 @@ export function injected_text(inject: string | null, placebo_chars: number): str
329
327
  return null;
330
328
  }
331
329
 
332
- /**
333
- * Workspace directory for one trial — delta #7 of the S0.3 spike.
334
- *
335
- * Keyed by `task|arm|seed`, not by task alone. The old task-only key meant every
336
- * arm and every seed of a task reused ONE directory that the next trial deleted,
337
- * so at the end of a sweep exactly one workspace survived per task — the last one
338
- * written, with no record of which arm or seed it belonged to.
339
- *
340
- * That is not a tidiness problem. Phase 3's anti-golfing gate is specified as
341
- * retro-fittable onto already-completed runs by offline re-scoring, and the
342
- * roadmap calls that gate cheap *because* the workspaces are preserved. Under the
343
- * old key there was nothing to re-score, so the claim was false. A distinct
344
- * directory per trial is what makes it true.
345
- *
346
- * `arm` is sanitised because arm names are used verbatim as a path segment and
347
- * one of them would otherwise be free to escape the root.
348
- */
349
- export function workspace_dir(task_id: string, arm: string, seed: number): string {
350
- const safe_arm = arm.replace(/[^A-Za-z0-9._-]/g, '_');
351
- return path.join(WORK_ROOT, `${task_id}__${safe_arm}__seed${seed}`);
352
- }
353
-
354
- /**
355
- * Copy the task's pristine fixture into this trial's own working clone.
356
- *
357
- * The clone is re-created from the fixture on every call, so a resumed or
358
- * repeated trial still starts pristine — the per-trial key changes *which*
359
- * directory that is, never whether it is clean.
360
- */
361
- export function reset_fixture(task: Dict, arm: string, seed: number): [string, string] {
362
- const fixture = path.join(FIXTURES_ROOT, String(task['fixture']));
363
- const dest = workspace_dir(String(task['id']), arm, seed);
364
- if (fs.existsSync(dest)) {
365
- fs.rmSync(dest, { recursive: true, force: true });
366
- }
367
- fs.mkdirSync(path.dirname(dest), { recursive: true });
368
- fs.cpSync(fixture, dest, { recursive: true });
369
- return [dest, fixture];
370
- }
371
330
 
372
331
  /** Map a run outcome to an AgentBench-style trajectory bucket. */
373
332
  export function status_bucket(run: Dict): string {
@@ -689,6 +648,7 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
689
648
  // rather than recomputed later, so an offline re-scorer reads the path the
690
649
  // sweep actually used instead of re-deriving a key that may have changed.
691
650
  workspace: clone,
651
+ transcript_path: preserve_transcript(clone, String(run['transcript'] ?? '')),
692
652
  ...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
693
653
  };
694
654
  }
@@ -803,6 +763,7 @@ export function selftest_run(
803
763
  metrics: trajectory_metrics(run, score),
804
764
  injected_chars: sp_text ? sp_text.length : 0,
805
765
  workspace: clone,
766
+ // No transcript_path — a synthetic transcript would give T5 a score of the harness.
806
767
  ...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
807
768
  };
808
769
  }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Offline re-scorer for the Phase-3 safety tier — T4.
4
+ *
5
+ * Reads a finished `ab-v2` report, walks every trial's **preserved workspace**
6
+ * (delta #7 keyed `task|arm|seed`), runs that task's adversarial probe against
7
+ * the code the trial produced, and writes one boolean into each trial's
8
+ * `metrics`:
9
+ *
10
+ * - `safety_tier_pass` — T4's endpoint. `true` the guard held, `false` it was
11
+ * dropped, and the key is **omitted** when the trial is unmeasurable.
12
+ *
13
+ * WHY OFFLINE, AND WHY THIS IS THE CHEAPER HALF OF THE STEP. Same three reasons
14
+ * as `bench_ab_v2_complexity.ts` — retro-fittable onto completed sweeps,
15
+ * deterministic, and no async in the synchronous live scorer — plus one this
16
+ * endpoint has on its own: the probes execute the trial's code, and executing
17
+ * arbitrary produced code inside the live sweep would put that execution on the
18
+ * paid path where a hang costs money. Here a hang costs a re-run of a free
19
+ * script.
20
+ *
21
+ * WHAT IT EXECUTES, AND THE ONE PLACE IT IS WIDER THAN THE CORPUS. `node
22
+ * <probe> <workspace>` with the repository root as the working directory, where
23
+ * the probe imports the trial's module. The probe itself is confined to the
24
+ * adversarial root, so a corpus entry cannot point it out of the tree with
25
+ * `../`. The **workspace** is not confined: it comes verbatim from a report
26
+ * file, and `solve_test` / `hidden_test` by contrast run inside a clone the
27
+ * sweep itself just created. So the honest statement is *narrower than the
28
+ * first draft of this header*: same class of trust, one degree wider in reach,
29
+ * and the mitigation is that a report is a local artefact you produced. No
30
+ * probe writes into a workspace — mutating the evidence would make a re-score
31
+ * unrepeatable.
32
+ *
33
+ * The report is rewritten in place only with `--write`; the default prints the
34
+ * table and touches nothing, because silently rewriting a pinned artefact is
35
+ * how a report stops matching the numbers already quoted from it.
36
+ *
37
+ * Usage:
38
+ * bench_ab_v2_safety.ts <report.json> [--write] [--json]
39
+ */
40
+ import { spawnSync } from 'node:child_process';
41
+ import * as fs from 'node:fs';
42
+ import * as path from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ import { parse as parseYaml } from 'yaml';
46
+
47
+ import {
48
+ type ProbeRunner,
49
+ type SafetyOracle,
50
+ safetyOracleOf,
51
+ safetyTierForWorkspace,
52
+ } from './_lib/bench_ab_safety_tier.js';
53
+
54
+ const _HERE = fileURLToPath(import.meta.url);
55
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
56
+ const ADVERSARIAL_ROOT = path.join(REPO_ROOT, 'internal', 'bench', 'ab');
57
+ const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-trackb-v2.yaml');
58
+
59
+ type Dict = Record<string, unknown>;
60
+
61
+ function _dictOr(v: unknown): Dict {
62
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Dict) : {};
63
+ }
64
+
65
+ /** Is `candidate` the root itself or below it? Both sides already resolved. */
66
+ export function isInside(root: string, candidate: string): boolean {
67
+ const rel = path.relative(root, candidate);
68
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
69
+ }
70
+
71
+ /**
72
+ * task id → its `safety_oracle`, for the tasks that carry one.
73
+ *
74
+ * A missing or unreadable corpus degrades every trial to "task id not in the
75
+ * corpus" rather than aborting — the same choice the complexity re-scorer makes,
76
+ * for the same reason: a re-score that still covers most of a report is worth
77
+ * more than an abort.
78
+ */
79
+ export function loadSafetyOracles(corpusPath: string | null): Map<string, SafetyOracle> {
80
+ const byId = new Map<string, SafetyOracle>();
81
+ if (!corpusPath || !fs.existsSync(corpusPath)) return byId;
82
+ try {
83
+ const corpus = _dictOr(parseYaml(fs.readFileSync(corpusPath, 'utf8'), { version: '1.1' }));
84
+ const tasks = Array.isArray(corpus['tasks']) ? (corpus['tasks'] as Dict[]) : [];
85
+ for (const t of tasks) {
86
+ const oracle = safetyOracleOf(t);
87
+ if (t['id'] !== undefined && oracle !== null) byId.set(String(t['id']), oracle);
88
+ }
89
+ } catch {
90
+ return byId;
91
+ }
92
+ return byId;
93
+ }
94
+
95
+ /** The default runner — `node <probe> <workspace>`, cwd = repository root. */
96
+ export const nodeProbeRunner: ProbeRunner = (probeAbs, workspace, timeoutS) => {
97
+ const r = spawnSync(process.execPath, [probeAbs, workspace], {
98
+ cwd: REPO_ROOT,
99
+ encoding: 'utf-8',
100
+ timeout: timeoutS * 1000,
101
+ });
102
+ const timedOut =
103
+ (r.error && (r.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') ||
104
+ (r.signal === 'SIGTERM' && Boolean(r.error));
105
+ if (timedOut) return { status: null, detail: `timeout after ${timeoutS}s` };
106
+ if (r.error) return { status: null, detail: `spawn failed: ${r.error.message}` };
107
+ return { status: r.status };
108
+ };
109
+
110
+ export interface SafetyRescore {
111
+ task: string;
112
+ arm: string;
113
+ seed: unknown;
114
+ /** `true` held · `false` breached · `null` not measured. */
115
+ safety_tier_pass: boolean | null;
116
+ exit_code: number | null;
117
+ reason: string;
118
+ }
119
+
120
+ /**
121
+ * Re-score one report payload. Mutates `payload` only when `write` is true.
122
+ *
123
+ * On a measured trial the boolean is written; on an unmeasured one any stale
124
+ * key is **deleted** rather than set to `false`. `compare()` reads a missing
125
+ * metric as "not measured on this pair", and a `false` would claim the arm
126
+ * dropped a guard — the one direction this endpoint must never guess in.
127
+ */
128
+ export function rescoreSafety(
129
+ payload: Dict,
130
+ opts: {
131
+ adversarialRoot?: string;
132
+ write?: boolean;
133
+ corpusPath?: string | null;
134
+ run?: ProbeRunner;
135
+ } = {},
136
+ ): SafetyRescore[] {
137
+ const adversarialRoot = opts.adversarialRoot ?? ADVERSARIAL_ROOT;
138
+ const run = opts.run ?? nodeProbeRunner;
139
+ const oracles = loadSafetyOracles(opts.corpusPath === undefined ? CORPUS_PATH : opts.corpusPath);
140
+ const out: SafetyRescore[] = [];
141
+ let wrote = 0;
142
+ // Reset on EVERY call, not only the writing ones: leaving the previous
143
+ // call's total in place makes `trialsWrittenByLastSafetyRescore()` correct
144
+ // only for a particular call order, which is a property of the caller
145
+ // rather than of the argument.
146
+ lastWriteCount = 0;
147
+ lastMutationCount = 0;
148
+ let mutated = 0;
149
+ const records = Array.isArray(payload['records']) ? (payload['records'] as Dict[]) : [];
150
+
151
+ for (const rec of records) {
152
+ const taskId = String(rec['id'] ?? '<unknown>');
153
+ const oracle = oracles.get(taskId) ?? null;
154
+ const arms = _dictOr(rec['arms']);
155
+ for (const [arm, runsRaw] of Object.entries(arms)) {
156
+ const runs = Array.isArray(runsRaw) ? (runsRaw as Dict[]) : [];
157
+ for (const trial of runs) {
158
+ const row: SafetyRescore = {
159
+ task: taskId,
160
+ arm,
161
+ seed: trial['seed'] ?? null,
162
+ safety_tier_pass: null,
163
+ exit_code: null,
164
+ reason: '',
165
+ };
166
+
167
+ const workspace = trial['workspace'] !== undefined ? String(trial['workspace']) : '';
168
+ if (oracle === null) {
169
+ row.reason = 'task carries no safety oracle';
170
+ } else if (!workspace || !fs.existsSync(workspace)) {
171
+ row.reason = workspace ? 'workspace missing on disk' : 'no workspace recorded';
172
+ } else if (!isInside(adversarialRoot, path.resolve(adversarialRoot, oracle.probe))) {
173
+ // A corpus-supplied path is data, and `path.join` happily
174
+ // normalises `../` straight out of the tree. Confinement is
175
+ // the cheap half of the trust boundary the header states.
176
+ row.reason = `probe path escapes the adversarial root: ${oracle.probe}`;
177
+ } else {
178
+ const probeAbs = path.join(adversarialRoot, oracle.probe);
179
+ const res = safetyTierForWorkspace({
180
+ workspace,
181
+ oracle,
182
+ probeAbs,
183
+ run,
184
+ exists: (p) => fs.existsSync(p),
185
+ });
186
+ row.safety_tier_pass = res.pass;
187
+ row.exit_code = res.exit_code;
188
+ row.reason = res.reason;
189
+ }
190
+
191
+ if (opts.write) {
192
+ const metrics = _dictOr(trial['metrics']);
193
+ const before = metrics['safety_tier_pass'];
194
+ if (row.safety_tier_pass === null) {
195
+ delete metrics['safety_tier_pass'];
196
+ } else {
197
+ metrics['safety_tier_pass'] = row.safety_tier_pass;
198
+ wrote += 1;
199
+ }
200
+ if (before !== metrics['safety_tier_pass']) mutated += 1;
201
+ trial['metrics'] = metrics;
202
+ }
203
+ out.push(row);
204
+ }
205
+ }
206
+ }
207
+ if (opts.write) {
208
+ lastWriteCount = wrote;
209
+ lastMutationCount = mutated;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * How many trials the last `rescoreSafety({ write: true })` actually measured.
216
+ * Reset to 0 at the start of every call, writing or not.
217
+ */
218
+ let lastWriteCount = 0;
219
+ export function trialsWrittenByLastSafetyRescore(): number {
220
+ return lastWriteCount;
221
+ }
222
+
223
+ /**
224
+ * How many trials the last write-mode call CHANGED — including the ones whose
225
+ * stale key it deleted.
226
+ *
227
+ * The CLI persists on this rather than on the measured count, because those two
228
+ * numbers disagree in exactly the case that matters: a re-score where every
229
+ * trial became unmeasurable measures zero and still has deletions to persist.
230
+ * Keying the write on the measured count made a stale value's survival depend
231
+ * on whether some *other* trial happened to be measurable — two opposite
232
+ * behaviours for one per-trial input.
233
+ */
234
+ let lastMutationCount = 0;
235
+ export function trialsMutatedByLastSafetyRescore(): number {
236
+ return lastMutationCount;
237
+ }
238
+
239
+ export function renderSafetyTable(rows: SafetyRescore[]): string {
240
+ const L: string[] = [];
241
+ L.push('task | arm | seed | safety | exit | note');
242
+ L.push('-----|-----|------|--------|------|-----');
243
+ for (const r of rows) {
244
+ L.push(
245
+ [
246
+ r.task,
247
+ r.arm,
248
+ String(r.seed ?? ''),
249
+ r.safety_tier_pass === null ? '-' : r.safety_tier_pass ? 'held' : 'BREACHED',
250
+ r.exit_code === null ? '-' : String(r.exit_code),
251
+ r.reason,
252
+ ].join(' | '),
253
+ );
254
+ }
255
+ const measured = rows.filter((r) => r.safety_tier_pass !== null).length;
256
+ const breached = rows.filter((r) => r.safety_tier_pass === false).length;
257
+ L.push('');
258
+ L.push(
259
+ `${measured}/${rows.length} trials carry a safety-tier observation; ${breached} breached.`,
260
+ );
261
+ return L.join('\n');
262
+ }
263
+
264
+ async function main(argv: string[]): Promise<number> {
265
+ const args = argv.filter((a) => !a.startsWith('--'));
266
+ const write = argv.includes('--write');
267
+ const asJson = argv.includes('--json');
268
+ const reportPath = args[0];
269
+ if (!reportPath) {
270
+ process.stderr.write('usage: bench_ab_v2_safety.ts <report.json> [--write] [--json]\n');
271
+ return 1;
272
+ }
273
+ if (!fs.existsSync(reportPath)) {
274
+ process.stderr.write(`report not found: ${reportPath}\n`);
275
+ return 1;
276
+ }
277
+ const payload = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as Dict;
278
+ const rows = rescoreSafety(payload, { write });
279
+ if (write && trialsMutatedByLastSafetyRescore() > 0) {
280
+ fs.writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
281
+ } else if (write) {
282
+ process.stderr.write('nothing changed — report left untouched\n');
283
+ }
284
+ process.stdout.write(asJson ? `${JSON.stringify(rows, null, 2)}\n` : `${renderSafetyTable(rows)}\n`);
285
+ return 0;
286
+ }
287
+
288
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
289
+ main(process.argv.slice(2)).then(
290
+ (code) => process.exit(code),
291
+ (err: unknown) => {
292
+ process.stderr.write(`${String(err)}\n`);
293
+ process.exit(1);
294
+ },
295
+ );
296
+ }