@event4u/agent-config 9.21.0 → 9.22.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Bootstrap shim for the event4u agent-config suite. This plugin ships ONLY the dispatcher hooks plus one install-pointer skill — ALL content (skills, rules, commands) is installed via `npx -y @event4u/agent-config init`, which writes the file projection AND registers the same hooks in ~/.claude/settings.json. DEPRECATED as a content channel; kept so the marketplace listing stays discoverable and existing installs keep working hooks.",
9
- "version": "9.21.0",
9
+ "version": "9.22.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -294,6 +294,41 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
294
294
  > that forces a new era split (`# Era: 9.19.x`, etc.) — see
295
295
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
296
296
 
297
+ ## [9.22.0](https://github.com/event4u-app/agent-config/compare/9.21.0...9.22.0) (2026-08-05)
298
+
299
+ ### Release highlights
300
+
301
+ <!-- Curated head: fill before merge, keep it under 10 lines, and leave `_none_` where it is genuinely the answer. The generated log below is unchanged. -->
302
+ - **Behaviour changes:** _none_
303
+ - **Default changes + migration:** _none_
304
+ - **Security and correctness:** _none_
305
+ - **Honest nulls:** _none_
306
+ - **Known limitations:** _none_
307
+
308
+ ### Features
309
+
310
+ * **worktree:** classify every worktree safe/review/live in one pass ([ac42fa6](https://github.com/event4u-app/agent-config/commit/ac42fa6c041db0343b6363472541a2fdf3815c3c))
311
+ * **bench:** report which arm the dropped pairs came from ([9230102](https://github.com/event4u-app/agent-config/commit/9230102868bd25a20aa2e30e35a9e57d45c0051c))
312
+ * **bench:** fail the sweep on an unproven arm, a swapped model, or an overrun ([490a5e5](https://github.com/event4u-app/agent-config/commit/490a5e5995db3e2f49676d7df5a0db41bf8e3926))
313
+ * **bench:** record which model actually answered each run ([5ea6b60](https://github.com/event4u-app/agent-config/commit/5ea6b60cb850e10299fe66c4c9e8aa0fd612772f))
314
+ * **bench:** the activation audit the invalid-nulls incident needed ([2630c48](https://github.com/event4u-app/agent-config/commit/2630c481b834b192c974cf828b72b2b3a3304b81))
315
+
316
+ ### Bug Fixes
317
+
318
+ * **bench:** validate the sweep cap before probing for the host CLI ([4aee1d8](https://github.com/event4u-app/agent-config/commit/4aee1d8aa02598db80d0cad819fe85618558dc53))
319
+
320
+ ### Documentation
321
+
322
+ * **roadmap:** promote worktree hygiene on the fired disk trigger, record the baseline ([78d5b7a](https://github.com/event4u-app/agent-config/commit/78d5b7ab2691b7cb75e094fa724e5a3eaa5d2f06))
323
+ * **worktree:** route whole-checkout sweeps through the inventory mode ([3ba4c55](https://github.com/event4u-app/agent-config/commit/3ba4c556a7eee4256be3378bd67aa962b884dede))
324
+ * **roadmap:** name why Measurement A cannot run, and correct my own scope ruling ([460dfb0](https://github.com/event4u-app/agent-config/commit/460dfb0c5e2d96e8deb831638684f501522661d2))
325
+ * **bench:** withdraw the interactions degradation rule — measured, then falsified ([56d3fe3](https://github.com/event4u-app/agent-config/commit/56d3fe3b1433c69419a7d118c567ea2581a0a0d2))
326
+ * **roadmap:** close the UI-track prerequisites and name the real blocker ([e8fa469](https://github.com/event4u-app/agent-config/commit/e8fa46953df5afacfb402a6d8468b44b72155191))
327
+ * **bench:** pre-register the UI-track tier and lane measurements ([3a8b601](https://github.com/event4u-app/agent-config/commit/3a8b6017965bc9eed189c1971f28a6212b9dc3d3))
328
+ * **roadmap:** record prerequisite work that moves no checkbox ([ad3bd4e](https://github.com/event4u-app/agent-config/commit/ad3bd4e9903e81ef38e647a6cbddacbd95f4f282))
329
+
330
+ Tests: 11091 (+57 since 9.21.0)
331
+
297
332
  ## [9.21.0](https://github.com/event4u-app/agent-config/compare/9.20.0...9.21.0) (2026-08-05)
298
333
 
299
334
  ### Release highlights
@@ -31,6 +31,23 @@ never force-removes.
31
31
  `git worktree list --porcelain` — target the worktree(s) named by the
32
32
  user, or every non-main worktree when asked to "clean up".
33
33
 
34
+ For a whole-checkout sweep, get the classified set in one pass instead of
35
+ running the per-worktree gate by hand:
36
+
37
+ ```bash
38
+ ./scripts-run src/scripts/worktree_cleanup_check inventory # counts + review reasons
39
+ ./scripts-run src/scripts/worktree_cleanup_check inventory --plan # commands for the safe set
40
+ ```
41
+
42
+ `safe` = on a branch, merged into the trunk, clean, in a conventional
43
+ worktree root, and no git activity for 48 h. `review` carries its
44
+ disqualifying reason; `live` means another session may hold it. The mode
45
+ reports only — running the plan is a bulk deletion needing the user's
46
+ explicit this-turn approval per
47
+ [`non-destructive-by-default`](../../../rules/non-destructive-by-default.md).
48
+ Present the safe set as a list and wait; a bulk sweep never inherits a
49
+ single earlier approval.
50
+
34
51
  ### 2. Per candidate, run the gates IN ORDER
35
52
 
36
53
  Run the deterministic gate helper (edge-case-tested: detached HEAD,
@@ -29,6 +29,12 @@ git worktree list --porcelain
29
29
  ```
30
30
 
31
31
  One report block per worktree (skip the main working tree unless asked).
32
+ Above roughly a dozen worktrees a per-worktree block stops being readable —
33
+ lead with the aggregate instead, then block only what the user asks about:
34
+
35
+ ```bash
36
+ ./scripts-run src/scripts/worktree_cleanup_check inventory
37
+ ```
32
38
 
33
39
  ### 2. Per worktree, gather
34
40
 
@@ -128,6 +128,28 @@ step (`scope-control`); never force-delete (`-D`) as part of cleanup.
128
128
  Cross-worktree scope-lock overlaps are scanned via
129
129
  `worktree_cleanup_check scope-overlap` (surfaced by `/worktree status`).
130
130
 
131
+ **Whole-checkout sweeps.** Worktrees accumulate one per branch and are never
132
+ removed on merge; `git worktree prune` only clears registrations whose
133
+ directory is already gone, so it does nothing for the live ones. Classify the
134
+ whole set in one pass rather than gate-checking by hand:
135
+
136
+ ```bash
137
+ npx tsx node_modules/@event4u/agent-config/src/scripts/worktree_cleanup_check.ts inventory [repo] [--json|--plan]
138
+ ```
139
+
140
+ `safe` requires all of: on a branch, merged into the trunk, clean, inside a
141
+ conventional worktree root (`.claude/worktrees/` or `.worktrees/`), and no git
142
+ activity for 48 h. `review` keeps its disqualifying reason so the next sweep
143
+ starts from a shorter list; `live` means another session may hold it. A
144
+ worktree outside the conventional roots is never `safe` — sitting beside the
145
+ repo it can be mistaken for a sibling package, so its removal stays a
146
+ judgement call.
147
+
148
+ The mode reports only. `--plan` prints `git worktree remove` plus
149
+ `git branch -d` (never `-D`) for the safe set; **running it is a bulk deletion
150
+ needing the user's explicit this-turn approval**
151
+ (`non-destructive-by-default`), which a single earlier approval never covers.
152
+
131
153
  ## Host-native mapping
132
154
 
133
155
  | Host capability | Use |
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.ts (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "9.21.0"
42
+ agent_config_version: "9.22.0"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -1,6 +1,6 @@
1
1
  # Discovery — Deprecation Report
2
2
 
3
- - Generated: `2026-08-05T03:10:28Z`
3
+ - Generated: `2026-08-05T05:32:20Z`
4
4
  - Deprecated artefacts: **0**
5
5
 
6
6
  _None. Tree is clean._
@@ -1977,7 +1977,7 @@
1977
1977
  },
1978
1978
  {
1979
1979
  "category": "command",
1980
- "checksum": "sha256:6efc669962a6f31d36731708ef37977505c8eaf29d4c9a6ef488037eed42ec1a",
1980
+ "checksum": "sha256:e0e438bb85349bc013a609e6def8d70e7b749dcd97adff313d5db535987f7e95",
1981
1981
  "install": {
1982
1982
  "default": true,
1983
1983
  "removable": false
@@ -2055,7 +2055,7 @@
2055
2055
  },
2056
2056
  {
2057
2057
  "category": "command",
2058
- "checksum": "sha256:0e0fcdddceaa2fe3c2ef63f8a6557182f310da33a04cfb53199fb20cebed0f26",
2058
+ "checksum": "sha256:68e5e30c2c90957615582840f804ce957bf1708319029b378ea97e384d05a1e8",
2059
2059
  "install": {
2060
2060
  "default": true,
2061
2061
  "removable": false
@@ -14220,7 +14220,7 @@
14220
14220
  },
14221
14221
  {
14222
14222
  "category": "skill",
14223
- "checksum": "sha256:66795187ffedce5896afb29d5c2794d7de0d55c7e2849b5bb001722bb0ba6975",
14223
+ "checksum": "sha256:6031d5e7109a96a5a105ac91ea80c0df66d9934594a11d0c3efcf8b596cc48cf",
14224
14224
  "install": {
14225
14225
  "default": true,
14226
14226
  "removable": false
@@ -14259,7 +14259,7 @@
14259
14259
  "workspaces": []
14260
14260
  }
14261
14261
  ],
14262
- "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
14262
+ "checksum": "sha256:acdebb0b72b50f8026c14b11df8706155fb82ba5c44cff7fa47b6e019052d156",
14263
14263
  "deprecations": [
14264
14264
  {
14265
14265
  "key": "tier",
@@ -14402,7 +14402,7 @@
14402
14402
  "reason": "scaffold template for ticket-bundle tickets (ADR-101); carries no runtime frontmatter — emit-tickets fills it in per ticket"
14403
14403
  }
14404
14404
  ],
14405
- "generated_at": "2026-08-05T03:10:28Z",
14405
+ "generated_at": "2026-08-05T05:32:20Z",
14406
14406
  "packs": [
14407
14407
  {
14408
14408
  "artefact_count": 117,
@@ -1 +1 @@
1
- 4b795a51a6574b400b90053e476daf9aac787dd71aa03ca8be86ad3f2913ea3e discovery-manifest.json
1
+ afd8d19ae939ff4cc011583591c8ae9f88601d301d808874f41cfb43f0d1122a discovery-manifest.json
@@ -1,6 +1,6 @@
1
1
  # Discovery Manifest — Summary
2
2
 
3
- - Generated: `2026-08-05T03:10:28Z`
3
+ - Generated: `2026-08-05T05:32:20Z`
4
4
  - Scanner: `c24312e116f8`
5
5
  - Artefacts: **594**
6
6
  - Unassigned: **0**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-08-05T03:10:28Z`
3
+ - Generated: `2026-08-05T05:32:20Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
- "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
3
- "generated_at": "2026-08-05T03:10:28Z",
2
+ "checksum": "sha256:acdebb0b72b50f8026c14b11df8706155fb82ba5c44cff7fa47b6e019052d156",
3
+ "generated_at": "2026-08-05T05:32:20Z",
4
4
  "packs": [
5
5
  {
6
6
  "always_on": false,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-08-05T03:10:28Z`
3
+ - Generated: `2026-08-05T05:32:20Z`
4
4
  - Workspaces tracked: **10**
5
5
  - Human-review-required artefacts: **3**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
3
- "generated_at": "2026-08-05T03:10:28Z",
2
+ "checksum": "sha256:acdebb0b72b50f8026c14b11df8706155fb82ba5c44cff7fa47b6e019052d156",
3
+ "generated_at": "2026-08-05T05:32:20Z",
4
4
  "scanner_version": "c24312e116f8",
5
5
  "workspaces": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "9.21.0"
12
+ "version": "9.22.0"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "9.21.0"
12
+ "version": "9.22.0"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "9.21.0"
19
+ "version": "9.22.0"
20
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "9.21.0",
4
+ "version": "9.22.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Measurement-integrity primitives for the bench:ab v2 paired sweep.
3
+ *
4
+ * Implements deltas #1–#4 of the S0.3 harness-feasibility spike
5
+ * (`agents/evidence/investigations/solution-minimalism-phase0-spikes.md`
6
+ * § "Deltas required before a paid run") — the four cheap ones the roadmap's
7
+ * Phase-3 halt note says "land before any spend".
8
+ *
9
+ * Why this exists at all: the sweep has ALREADY produced a full set of invalid
10
+ * nulls once. Clones lived inside the repo, so the `vanilla` arm inherited the
11
+ * package through project scope and every prior null was void
12
+ * (`bench_ab_v2_run.ts` § arm-isolation note). The fix at the time was a `/tmp`
13
+ * path constant with no runtime assertion behind it, and the only activation
14
+ * field on a trial record — `injected_chars` — is the `String.length` of a file
15
+ * the harness itself wrote. It proves nothing about the model, and it is `0` by
16
+ * construction for the `package` arm, which arrives through global settings.
17
+ * If the plugin were disabled or version-drifted mid-sweep, every treatment run
18
+ * would silently degrade to `vanilla` and the report would look identical.
19
+ *
20
+ * Everything here is pure and host-free so it can be unit-tested without a
21
+ * model call. The sweep wires it in `bench_ab_v2_run.ts`.
22
+ */
23
+
24
+ /** Four-bucket usage split as reported by the Claude CLI JSON envelope. */
25
+ export interface TokensBreakdown {
26
+ input_tokens: number;
27
+ output_tokens: number;
28
+ cache_read_input_tokens: number;
29
+ cache_creation_input_tokens: number;
30
+ }
31
+
32
+ /** Per-1M-token rates for one pricing tier (mirrors `_lib/bench_cost.TierRates`). */
33
+ export interface TierRates {
34
+ input: number;
35
+ output: number;
36
+ cache_write: number;
37
+ cache_read: number;
38
+ }
39
+
40
+ /**
41
+ * How an arm is supposed to reach the model.
42
+ *
43
+ * - `plugin` — the package rides global settings (`setting_sources: null`).
44
+ * Invisible to `injected_chars`; only the prompt footprint can see it.
45
+ * - `text` — an `--append-system-prompt-file` injection the harness wrote.
46
+ * - `none` — the baseline: settings scoped away AND no text injection.
47
+ */
48
+ export type ExpectedInjection = 'plugin' | 'text' | 'none';
49
+
50
+ /**
51
+ * `unknown` is deliberately not a violation: an errored or budget-capped run
52
+ * carries a zeroed usage block, so its footprint says nothing. Attrition is
53
+ * reported separately (delta #5) rather than laundered into an integrity fail.
54
+ */
55
+ export type ActivationVerdict = 'ok' | 'violation' | 'unknown';
56
+
57
+ export interface Activation {
58
+ expected: ExpectedInjection;
59
+ prompt_tokens: number;
60
+ injected_chars: number;
61
+ verdict: ActivationVerdict;
62
+ reason: string;
63
+ }
64
+
65
+ export interface ActivationViolation {
66
+ task_id: string;
67
+ seed: number;
68
+ arm: string;
69
+ kind: 'text-injection-missing' | 'text-injection-unexpected' | 'collapsed-to-baseline' | 'model-mismatch';
70
+ detail: string;
71
+ }
72
+
73
+ export interface ActivationAudit {
74
+ checked: number;
75
+ skipped: number;
76
+ violations: ActivationViolation[];
77
+ }
78
+
79
+ /**
80
+ * Minimum prompt-footprint ratio a plugin-bearing arm must hold over the paired
81
+ * baseline run before the pair is believed.
82
+ *
83
+ * Calibrated DOWNWARD from the three stored sweeps in S0.3's cost sheet, whose
84
+ * observed treatment/vanilla total-token ratios were 4.68× (sonnet, `package`),
85
+ * 1.71× (haiku, `rules-kernel-dc`) and 6.79× (haiku, `package`). 1.2 sits below
86
+ * the smallest of those by a wide margin on purpose: this flags a COLLAPSE to
87
+ * baseline — a disabled or drifted plugin — never ordinary run-to-run variance.
88
+ * Raising it toward the observed values would start failing legitimate runs;
89
+ * lowering it toward 1.0 would stop catching the failure it exists for.
90
+ */
91
+ export const ACTIVATION_MIN_LIFT_RATIO = 1.2;
92
+
93
+ /**
94
+ * The footprint an injection actually moves: everything the model was fed.
95
+ *
96
+ * Output tokens are excluded — they measure how much the model wrote, which a
97
+ * bigger system prompt does not reliably change, so including them would blur
98
+ * the very signal this audit reads.
99
+ */
100
+ export function prompt_tokens(b: TokensBreakdown | Record<string, never> | null | undefined): number {
101
+ if (!b) {
102
+ return 0;
103
+ }
104
+ const t = b as Partial<TokensBreakdown>;
105
+ return _int(t.input_tokens) + _int(t.cache_read_input_tokens) + _int(t.cache_creation_input_tokens);
106
+ }
107
+
108
+ /** Classify an arm spec into the injection channel it is supposed to use. */
109
+ export function expected_injection(spec: { setting_sources: string | null; inject: string | null }): ExpectedInjection {
110
+ if (spec.inject) {
111
+ return 'text';
112
+ }
113
+ return spec.setting_sources === null ? 'plugin' : 'none';
114
+ }
115
+
116
+ /**
117
+ * Per-trial verdict — the direction that IS decidable from one run.
118
+ *
119
+ * A text injection is checked both ways: an arm that declares one must carry
120
+ * it, and an arm that declares none must not. The plugin direction needs the
121
+ * paired baseline and is handled by `audit_activation`.
122
+ */
123
+ export function activation_verdict(args: {
124
+ expected: ExpectedInjection;
125
+ tokens_breakdown: TokensBreakdown | Record<string, never> | null | undefined;
126
+ injected_chars: number;
127
+ errored: boolean;
128
+ }): Activation {
129
+ const pt = prompt_tokens(args.tokens_breakdown);
130
+ const base = { expected: args.expected, prompt_tokens: pt, injected_chars: args.injected_chars };
131
+ if (args.errored || pt === 0) {
132
+ return { ...base, verdict: 'unknown', reason: 'errored or zero usage — footprint unreadable' };
133
+ }
134
+ if (args.expected === 'text' && args.injected_chars <= 0) {
135
+ return { ...base, verdict: 'violation', reason: 'text arm carried no injection' };
136
+ }
137
+ if (args.expected !== 'text' && args.injected_chars > 0) {
138
+ return {
139
+ ...base,
140
+ verdict: 'violation',
141
+ reason: `${args.expected} arm carried a ${args.injected_chars}-char injection`,
142
+ };
143
+ }
144
+ return { ...base, verdict: 'ok', reason: 'expected channel present' };
145
+ }
146
+
147
+ /**
148
+ * Cross-arm audit over a finished (or partial) record set — the plugin
149
+ * direction plus every per-trial verdict already stamped on the records.
150
+ *
151
+ * A pair is only judged when BOTH sides are readable; unreadable pairs are
152
+ * counted as `skipped`, never silently passed.
153
+ */
154
+ export function audit_activation(
155
+ records: readonly Record<string, unknown>[],
156
+ opts: { baseline_arm: string; lift_arms: readonly string[]; min_ratio?: number },
157
+ ): ActivationAudit {
158
+ const minRatio = opts.min_ratio ?? ACTIVATION_MIN_LIFT_RATIO;
159
+ const violations: ActivationViolation[] = [];
160
+ let checked = 0;
161
+ let skipped = 0;
162
+
163
+ for (const rec of records) {
164
+ const taskId = String(rec['id'] ?? '');
165
+ const arms = _dict(rec['arms']);
166
+
167
+ for (const [arm, runsRaw] of Object.entries(arms)) {
168
+ for (const run of _list(runsRaw)) {
169
+ const act = _dict(run['activation']);
170
+ if (act['verdict'] === 'violation') {
171
+ const reason = String(act['reason'] ?? '');
172
+ violations.push({
173
+ task_id: taskId,
174
+ seed: _int(run['seed']),
175
+ arm,
176
+ kind: reason.includes('no injection') ? 'text-injection-missing' : 'text-injection-unexpected',
177
+ detail: reason,
178
+ });
179
+ }
180
+ const mc = _dict(run['model_check']);
181
+ if ('ok' in mc && !_truthy(mc['ok'])) {
182
+ violations.push({
183
+ task_id: taskId,
184
+ seed: _int(run['seed']),
185
+ arm,
186
+ kind: 'model-mismatch',
187
+ detail: String(mc['reason'] ?? ''),
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ const baseBySeed = new Map<number, number>();
194
+ for (const run of _list(arms[opts.baseline_arm])) {
195
+ const pt = _runPromptTokens(run);
196
+ if (!_truthy(run['errored']) && pt > 0) {
197
+ baseBySeed.set(_int(run['seed']), pt);
198
+ }
199
+ }
200
+
201
+ for (const arm of opts.lift_arms) {
202
+ for (const run of _list(arms[arm])) {
203
+ const seed = _int(run['seed']);
204
+ const basePt = baseBySeed.get(seed);
205
+ const armPt = _runPromptTokens(run);
206
+ if (_truthy(run['errored']) || basePt === undefined || armPt === 0) {
207
+ skipped += 1;
208
+ continue;
209
+ }
210
+ checked += 1;
211
+ if (armPt < basePt * minRatio) {
212
+ violations.push({
213
+ task_id: taskId,
214
+ seed,
215
+ arm,
216
+ kind: 'collapsed-to-baseline',
217
+ detail:
218
+ `prompt footprint ${armPt} vs baseline ${basePt} ` +
219
+ `(ratio ${(armPt / basePt).toFixed(2)} < ${minRatio}) — ` +
220
+ 'the treatment surface may not have reached the model',
221
+ });
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ return { checked, skipped, violations };
228
+ }
229
+
230
+ // ── delta #3 — model-id verification ────────────────────────────────────────
231
+
232
+ /**
233
+ * Aliases the Claude CLI resolves server-side to whatever is current.
234
+ *
235
+ * A stored report already contains the bare alias `"sonnet"`, which makes that
236
+ * report unreproducible: nothing in it records which model actually answered.
237
+ */
238
+ export const BARE_MODEL_ALIASES: readonly string[] = ['default', 'haiku', 'opus', 'sonnet', 'sonnet[1m]'];
239
+
240
+ /** True when `model` is an alias rather than a pinned, reproducible id. */
241
+ export function is_bare_alias(model: string): boolean {
242
+ return BARE_MODEL_ALIASES.includes(model.trim().toLowerCase());
243
+ }
244
+
245
+ /** Drop a trailing `-YYYYMMDD` build stamp so `claude-x-1` == `claude-x-1-20250101`. */
246
+ export function normalize_model_id(model: string): string {
247
+ return model.trim().toLowerCase().replace(/-\d{8}$/, '');
248
+ }
249
+
250
+ /**
251
+ * Compare the requested model against the ids the CLI actually billed.
252
+ *
253
+ * `models_seen` comes from the envelope's `modelUsage` keys — the only place
254
+ * the run states which model answered. An empty list is `ok`: some envelopes
255
+ * omit the block, and refusing there would fail runs for a reporting gap
256
+ * rather than a real mismatch.
257
+ */
258
+ export function verify_model_id(requested: string, models_seen: readonly string[]): { ok: boolean; reason: string } {
259
+ if (is_bare_alias(requested)) {
260
+ return { ok: false, reason: `requested model "${requested}" is a bare alias — pin a full model id` };
261
+ }
262
+ if (models_seen.length === 0) {
263
+ return { ok: true, reason: 'envelope reported no model usage — nothing to contradict' };
264
+ }
265
+ const want = normalize_model_id(requested);
266
+ const seen = models_seen.map(normalize_model_id);
267
+ if (seen.every((s) => s === want)) {
268
+ return { ok: true, reason: `billed model matches ${requested}` };
269
+ }
270
+ return {
271
+ ok: false,
272
+ reason: `requested ${requested} but envelope billed ${[...new Set(models_seen)].sort().join(', ')}`,
273
+ };
274
+ }
275
+
276
+ // ── delta #4 — sweep-level cost ─────────────────────────────────────────────
277
+
278
+ /** Map a full model id onto a `pricing.yaml` tier, or null when unrecognised. */
279
+ export function tier_for_model(model: string): string | null {
280
+ const m = model.toLowerCase();
281
+ for (const tier of ['haiku', 'sonnet', 'opus']) {
282
+ if (m.includes(tier)) {
283
+ return tier;
284
+ }
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * Price one run's four buckets separately.
291
+ *
292
+ * The buckets differ in price by up to 125×, so a single blended rate over the
293
+ * summed total is not an approximation — it is a different number.
294
+ */
295
+ export function cost_usd(b: TokensBreakdown | Record<string, never> | null | undefined, rates: TierRates): number {
296
+ if (!b) {
297
+ return 0;
298
+ }
299
+ const t = b as Partial<TokensBreakdown>;
300
+ return (
301
+ (_int(t.input_tokens) * rates.input +
302
+ _int(t.output_tokens) * rates.output +
303
+ _int(t.cache_creation_input_tokens) * rates.cache_write +
304
+ _int(t.cache_read_input_tokens) * rates.cache_read) /
305
+ 1_000_000
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Cumulative sweep spend with a hard cap.
311
+ *
312
+ * `--max-budget-usd` is per RUN (default 1.0), so a 30×4×3 sweep at `--budget
313
+ * 3.5` has a $1,260 ceiling with nothing to stop it. This is the sweep-level
314
+ * counterpart: the first run that pushes cumulative spend over the cap aborts
315
+ * the sweep instead of discovering the overrun on the invoice.
316
+ */
317
+ export class SweepBudget {
318
+ private _spent = 0;
319
+
320
+ constructor(
321
+ readonly cap_usd: number | null,
322
+ private readonly rates: TierRates | null,
323
+ ) {}
324
+
325
+ get spent_usd(): number {
326
+ return this._spent;
327
+ }
328
+
329
+ /** Add one run's cost; return an abort reason when the cap is crossed. */
330
+ add(b: TokensBreakdown | Record<string, never> | null | undefined): string | null {
331
+ if (!this.rates) {
332
+ return null;
333
+ }
334
+ this._spent += cost_usd(b, this.rates);
335
+ if (this.cap_usd !== null && this._spent > this.cap_usd) {
336
+ return `sweep budget abort: cumulative $${this._spent.toFixed(2)} > cap $${this.cap_usd.toFixed(2)}`;
337
+ }
338
+ return null;
339
+ }
340
+ }
341
+
342
+ // ── internals ───────────────────────────────────────────────────────────────
343
+
344
+ function _int(v: unknown): number {
345
+ return typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : 0;
346
+ }
347
+
348
+ function _dict(v: unknown): Record<string, unknown> {
349
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
350
+ }
351
+
352
+ function _list(v: unknown): Record<string, unknown>[] {
353
+ return Array.isArray(v) ? (v as Record<string, unknown>[]) : [];
354
+ }
355
+
356
+ function _truthy(v: unknown): boolean {
357
+ return Boolean(v);
358
+ }
359
+
360
+ /**
361
+ * Prompt footprint of one trial record — the stamped `activation` block when
362
+ * the sweep wrote one, else recomputed from the preserved breakdown (delta #2),
363
+ * so the audit also reads reports written before the stamp existed.
364
+ */
365
+ function _runPromptTokens(run: Record<string, unknown>): number {
366
+ const stamped = _int(_dict(run['activation'])['prompt_tokens']);
367
+ return stamped > 0 ? stamped : prompt_tokens(run['tokens_breakdown'] as TokensBreakdown | undefined);
368
+ }