@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +273 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +44 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +10625 -8541
- package/dist/install/install.mjs +754 -484
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1302 -167
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/MIGRATION.md +20 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +63 -11
- package/src/config/gate-violation-baselines.json +12 -0
- package/src/config/hook-token-budget.json +15 -2
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +10 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +1115 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +197 -18
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +279 -317
- package/src/scripts/check_branch_freshness.ts +7 -1
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +75 -15
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +100 -6
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +3 -3
- package/src/scripts/schemas/skill.schema.json +20 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +2 -1
- package/src/server/schemas/settings.ts +2 -2
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
- package/src/config/budget-routing.json +0 -7
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Offline re-scorer for the Phase-3 search-adherence endpoint — T5.
|
|
4
|
+
*
|
|
5
|
+
* Reads a finished `ab-v2` report, loads each trial's **preserved transcript**
|
|
6
|
+
* (the sibling file `bench_ab_v2_run.preserve_transcript` writes next to the
|
|
7
|
+
* clone), scores it against the k=2 rubric in
|
|
8
|
+
* `_lib/bench_ab_search_adherence.ts`, and writes one number into each trial's
|
|
9
|
+
* `metrics`:
|
|
10
|
+
*
|
|
11
|
+
* - `search_adherence` — fraction of rubric items **both** judges credited.
|
|
12
|
+
* Omitted, never zeroed, when the trial is unmeasurable.
|
|
13
|
+
*
|
|
14
|
+
* WHY OFFLINE. T5 is the one endpoint that genuinely needs model calls, which
|
|
15
|
+
* is exactly why it must not run inside the sweep: a judge failure or a rate
|
|
16
|
+
* limit mid-sweep would stall the metered path, and re-judging would mean
|
|
17
|
+
* re-running the arm. Freezing the transcript and judging afterwards is the
|
|
18
|
+
* `anchor_scoring_run.ts` shape — reproducibility comes from freezing, not from
|
|
19
|
+
* pinning a model.
|
|
20
|
+
*
|
|
21
|
+
* WHY IT COSTS MONEY AND WHAT THAT MEANS HERE. Two judge calls per trial (plus
|
|
22
|
+
* at most one retry each) is real spend, so **the default is the mock judge**:
|
|
23
|
+
* deterministic, no key, no network, exercised by the tests. Live judging is
|
|
24
|
+
* opt-in via `--live`, which is the only path that touches a key. There is no
|
|
25
|
+
* `--dry-run` flag — the absence of `--live` IS the dry run, and an earlier
|
|
26
|
+
* version of this line named a flag `main` does not parse.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* bench_ab_v2_search.ts <report.json> [--live] [--write] [--json]
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
import { parse as parseYaml } from 'yaml';
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
type AskFn,
|
|
40
|
+
SEARCH_ADHERENCE_K,
|
|
41
|
+
scoreSearchAdherence,
|
|
42
|
+
transcriptFromPrompt,
|
|
43
|
+
} from './_lib/bench_ab_search_adherence.js';
|
|
44
|
+
|
|
45
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
46
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
47
|
+
const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-trackb-v2.yaml');
|
|
48
|
+
|
|
49
|
+
type Dict = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
function _dictOr(v: unknown): Dict {
|
|
52
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Dict) : {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** task id → the prompt the run was given; the judge needs it for context. */
|
|
56
|
+
export function loadTaskPrompts(corpusPath: string | null): Map<string, string> {
|
|
57
|
+
const byId = new Map<string, string>();
|
|
58
|
+
if (!corpusPath || !fs.existsSync(corpusPath)) return byId;
|
|
59
|
+
try {
|
|
60
|
+
const corpus = _dictOr(parseYaml(fs.readFileSync(corpusPath, 'utf8'), { version: '1.1' }));
|
|
61
|
+
const tasks = Array.isArray(corpus['tasks']) ? (corpus['tasks'] as Dict[]) : [];
|
|
62
|
+
for (const t of tasks) {
|
|
63
|
+
if (t['id'] !== undefined) byId.set(String(t['id']), String(t['prompt'] ?? ''));
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return byId;
|
|
67
|
+
}
|
|
68
|
+
return byId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SearchRescore {
|
|
72
|
+
task: string;
|
|
73
|
+
arm: string;
|
|
74
|
+
seed: unknown;
|
|
75
|
+
search_adherence: number | null;
|
|
76
|
+
reason: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-score one report payload. Mutates `payload` only when `write` is true.
|
|
81
|
+
*
|
|
82
|
+
* On an unmeasured trial any stale key is **deleted** rather than set to 0 —
|
|
83
|
+
* `compare()` reads a missing metric as "not measured on this pair", while a 0
|
|
84
|
+
* would assert the run searched for nothing.
|
|
85
|
+
*/
|
|
86
|
+
export function rescoreSearch(
|
|
87
|
+
payload: Dict,
|
|
88
|
+
opts: {
|
|
89
|
+
asks: AskFn[];
|
|
90
|
+
write?: boolean;
|
|
91
|
+
corpusPath?: string | null;
|
|
92
|
+
readTranscript?: (p: string) => string | null;
|
|
93
|
+
},
|
|
94
|
+
): SearchRescore[] {
|
|
95
|
+
const prompts = loadTaskPrompts(opts.corpusPath === undefined ? CORPUS_PATH : opts.corpusPath);
|
|
96
|
+
const readTranscript =
|
|
97
|
+
opts.readTranscript ??
|
|
98
|
+
((p: string): string | null => {
|
|
99
|
+
try {
|
|
100
|
+
return fs.readFileSync(p, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const out: SearchRescore[] = [];
|
|
106
|
+
let wrote = 0;
|
|
107
|
+
// Reset every call — see the same note in bench_ab_v2_safety.ts.
|
|
108
|
+
lastWriteCount = 0;
|
|
109
|
+
lastMutationCount = 0;
|
|
110
|
+
let mutated = 0;
|
|
111
|
+
const records = Array.isArray(payload['records']) ? (payload['records'] as Dict[]) : [];
|
|
112
|
+
|
|
113
|
+
for (const rec of records) {
|
|
114
|
+
const taskId = String(rec['id'] ?? '<unknown>');
|
|
115
|
+
const taskPrompt = prompts.get(taskId) ?? '';
|
|
116
|
+
const arms = _dictOr(rec['arms']);
|
|
117
|
+
for (const [arm, runsRaw] of Object.entries(arms)) {
|
|
118
|
+
const runs = Array.isArray(runsRaw) ? (runsRaw as Dict[]) : [];
|
|
119
|
+
for (const trial of runs) {
|
|
120
|
+
const row: SearchRescore = {
|
|
121
|
+
task: taskId,
|
|
122
|
+
arm,
|
|
123
|
+
seed: trial['seed'] ?? null,
|
|
124
|
+
search_adherence: null,
|
|
125
|
+
reason: '',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const tp = trial['transcript_path'];
|
|
129
|
+
const transcriptPath = tp === undefined || tp === null ? '' : String(tp);
|
|
130
|
+
if (!transcriptPath) {
|
|
131
|
+
// Three different causes land here and the re-scorer cannot
|
|
132
|
+
// tell them apart from the report alone: a sweep older than
|
|
133
|
+
// transcript preservation, a trial whose transcript was
|
|
134
|
+
// empty, and the `package-recursive` arm, which records
|
|
135
|
+
// neither a workspace nor a transcript. Naming one of the
|
|
136
|
+
// three as THE reason was the first version of this line and
|
|
137
|
+
// it was wrong for a sweep run today.
|
|
138
|
+
row.reason =
|
|
139
|
+
'no transcript recorded (older report, empty transcript, ' +
|
|
140
|
+
'or an arm that preserves none — e.g. package-recursive)';
|
|
141
|
+
} else {
|
|
142
|
+
const transcript = readTranscript(transcriptPath);
|
|
143
|
+
if (transcript === null) {
|
|
144
|
+
row.reason = 'transcript missing on disk';
|
|
145
|
+
} else if (taskPrompt === '') {
|
|
146
|
+
row.reason = 'task id not in the corpus';
|
|
147
|
+
} else {
|
|
148
|
+
const res = scoreSearchAdherence({ asks: opts.asks, taskPrompt, transcript });
|
|
149
|
+
row.search_adherence = res.score;
|
|
150
|
+
row.reason = res.reason;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (opts.write) {
|
|
155
|
+
const metrics = _dictOr(trial['metrics']);
|
|
156
|
+
const before = metrics['search_adherence'];
|
|
157
|
+
if (row.search_adherence === null) {
|
|
158
|
+
delete metrics['search_adherence'];
|
|
159
|
+
} else {
|
|
160
|
+
metrics['search_adherence'] = row.search_adherence;
|
|
161
|
+
wrote += 1;
|
|
162
|
+
}
|
|
163
|
+
if (before !== metrics['search_adherence']) mutated += 1;
|
|
164
|
+
trial['metrics'] = metrics;
|
|
165
|
+
}
|
|
166
|
+
out.push(row);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (opts.write) {
|
|
171
|
+
lastWriteCount = wrote;
|
|
172
|
+
lastMutationCount = mutated;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Trials the last write-mode call measured. Reset to 0 on every call. */
|
|
178
|
+
let lastWriteCount = 0;
|
|
179
|
+
export function trialsWrittenByLastSearchRescore(): number {
|
|
180
|
+
return lastWriteCount;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Trials the last write-mode call CHANGED, deletions included — the number the
|
|
185
|
+
* CLI persists on. See the same note in `bench_ab_v2_safety.ts`.
|
|
186
|
+
*/
|
|
187
|
+
let lastMutationCount = 0;
|
|
188
|
+
export function trialsMutatedByLastSearchRescore(): number {
|
|
189
|
+
return lastMutationCount;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The dry-run judge — deterministic, keyless, and deliberately NOT all-yes.
|
|
194
|
+
*
|
|
195
|
+
* It credits an item only when the transcript literally carries the evidence
|
|
196
|
+
* shape that item asks about, so a dry run over a real corpus produces a spread
|
|
197
|
+
* rather than a flat ceiling. An all-yes mock would make every smoke test pass
|
|
198
|
+
* while telling nobody whether the pipeline can express a `no`.
|
|
199
|
+
*/
|
|
200
|
+
export function mockJudge(prompt: string): string {
|
|
201
|
+
// `transcriptFromPrompt`, never a split on the header: the rubric that
|
|
202
|
+
// follows the transcript contains every keyword below, so a naive slice
|
|
203
|
+
// scores the instrument and returns an all-yes ceiling for any input.
|
|
204
|
+
const body = transcriptFromPrompt(prompt);
|
|
205
|
+
const named = /\b(reuse|existing|already have|instead of writing)\b/i.test(body);
|
|
206
|
+
// `cat ` / `ls ` must NOT be followed by `\b`: a word boundary after a space
|
|
207
|
+
// demands a word character next, so `cat /etc/hosts` and `ls -la` — the two
|
|
208
|
+
// shapes a transcript actually contains — never matched. Their boundary goes
|
|
209
|
+
// BEFORE the token instead.
|
|
210
|
+
const inspected = /\b(read|grep|opened|listed)\b|\b(cat|ls) /i.test(body);
|
|
211
|
+
const justified = /\b(because|so I|therefore|rather than)\b/i.test(body);
|
|
212
|
+
return [
|
|
213
|
+
`NAMED: ${named ? 'yes' : 'no'}`,
|
|
214
|
+
`INSPECTED: ${inspected ? 'yes' : 'no'}`,
|
|
215
|
+
`JUSTIFIED: ${justified ? 'yes' : 'no'}`,
|
|
216
|
+
].join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Build the two live judges.
|
|
221
|
+
*
|
|
222
|
+
* The client import is lazy and lives inside this function on purpose: nothing
|
|
223
|
+
* that imports this module for its pure parts should resolve a key path, which
|
|
224
|
+
* is what keeps the whole test suite keyless. A missing key is a hard exit with
|
|
225
|
+
* a pointer, never a silent degrade to one judge — k=2 is pre-registered, and
|
|
226
|
+
* quietly running at k=1 would report a number the record does not describe.
|
|
227
|
+
*/
|
|
228
|
+
function liveJudges(model: { anthropic: string; openai: string }): AskFn[] {
|
|
229
|
+
const require_ = createRequire(_HERE);
|
|
230
|
+
const clients = require_('./ai_council/clients.js') as {
|
|
231
|
+
AnthropicClient: new (o: { model: string; api_key: string }) => { ask(s: string, u: string): { text: string } };
|
|
232
|
+
OpenAIClient: new (o: { model: string; api_key: string }) => { ask(s: string, u: string): { text: string } };
|
|
233
|
+
load_anthropic_key: () => string;
|
|
234
|
+
load_openai_key: () => string;
|
|
235
|
+
};
|
|
236
|
+
const system = 'You are a strict evidence scorer. Answer only in the requested three-line format.';
|
|
237
|
+
const a = new clients.AnthropicClient({
|
|
238
|
+
model: model.anthropic,
|
|
239
|
+
api_key: clients.load_anthropic_key(),
|
|
240
|
+
});
|
|
241
|
+
const o = new clients.OpenAIClient({ model: model.openai, api_key: clients.load_openai_key() });
|
|
242
|
+
return [
|
|
243
|
+
(p: string) => requireText(a.ask(system, p).text, 'anthropic'),
|
|
244
|
+
(p: string) => requireText(o.ask(system, p).text, 'openai'),
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Assert the client returned a string.
|
|
250
|
+
*
|
|
251
|
+
* The `as` cast above describes the client surface by hand, so TypeScript
|
|
252
|
+
* verifies nothing about it: if a client's `ask` ever becomes async, `.text` is
|
|
253
|
+
* `undefined`, both judges return incomplete readings, and EVERY trial reports
|
|
254
|
+
* unmeasured — a silent, total, and entirely plausible-looking null on the only
|
|
255
|
+
* path that spends money. Throwing turns that into one loud failure, which the
|
|
256
|
+
* caller already converts into a hard exit.
|
|
257
|
+
*/
|
|
258
|
+
function requireText(value: unknown, vendor: string): string {
|
|
259
|
+
if (typeof value !== 'string') {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`${vendor} judge returned ${typeof value}, not a string — ` +
|
|
262
|
+
'the client surface changed (async ask?), and AskFn is synchronous',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function main(argv: string[]): Promise<number> {
|
|
269
|
+
const args = argv.filter((a) => !a.startsWith('--'));
|
|
270
|
+
const write = argv.includes('--write');
|
|
271
|
+
const asJson = argv.includes('--json');
|
|
272
|
+
const live = argv.includes('--live');
|
|
273
|
+
const reportPath = args[0];
|
|
274
|
+
if (!reportPath) {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
'usage: bench_ab_v2_search.ts <report.json> [--live] [--write] [--json]\n',
|
|
277
|
+
);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
if (!fs.existsSync(reportPath)) {
|
|
281
|
+
process.stderr.write(`report not found: ${reportPath}\n`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let asks: AskFn[];
|
|
286
|
+
if (live) {
|
|
287
|
+
try {
|
|
288
|
+
asks = liveJudges({ anthropic: 'claude-sonnet-4-5', openai: 'gpt-5' });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`error: live judging unavailable (${String(err)}). ` +
|
|
292
|
+
`T5 is pre-registered at k=${SEARCH_ADHERENCE_K}; running with fewer judges ` +
|
|
293
|
+
'would report a number the record does not describe. Use --dry-run semantics ' +
|
|
294
|
+
'(omit --live) or install the keys.\n',
|
|
295
|
+
);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
process.stderr.write('⚠️ no --live: deterministic mock judges, no API, no spend.\n');
|
|
300
|
+
asks = [mockJudge, mockJudge];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const payload = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as Dict;
|
|
304
|
+
const rows = rescoreSearch(payload, { asks, write });
|
|
305
|
+
if (write && trialsMutatedByLastSearchRescore() > 0) {
|
|
306
|
+
fs.writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
307
|
+
} else if (write) {
|
|
308
|
+
process.stderr.write('nothing changed — report left untouched\n');
|
|
309
|
+
}
|
|
310
|
+
process.stdout.write(asJson ? `${JSON.stringify(rows, null, 2)}\n` : `${renderSearchTable(rows)}\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function renderSearchTable(rows: SearchRescore[]): string {
|
|
315
|
+
const L: string[] = [];
|
|
316
|
+
L.push('task | arm | seed | search | note');
|
|
317
|
+
L.push('-----|-----|------|--------|-----');
|
|
318
|
+
for (const r of rows) {
|
|
319
|
+
L.push(
|
|
320
|
+
[
|
|
321
|
+
r.task,
|
|
322
|
+
r.arm,
|
|
323
|
+
String(r.seed ?? ''),
|
|
324
|
+
r.search_adherence === null ? '-' : r.search_adherence.toFixed(2),
|
|
325
|
+
r.reason,
|
|
326
|
+
].join(' | '),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const measured = rows.filter((r) => r.search_adherence !== null).length;
|
|
330
|
+
L.push('');
|
|
331
|
+
L.push(`${measured}/${rows.length} trials carry a search-adherence observation.`);
|
|
332
|
+
return L.join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
|
|
336
|
+
main(process.argv.slice(2)).then(
|
|
337
|
+
(code) => process.exit(code),
|
|
338
|
+
(err: unknown) => {
|
|
339
|
+
process.stderr.write(`${String(err)}\n`);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
}
|