@event4u/agent-config 9.25.0 → 9.26.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +2 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +194 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/delegation-policy.md +3 -2
  10. package/dist/agent-src/rules/design-fidelity.md +17 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  12. package/dist/agent-src/rules/evaluator-independence.md +9 -3
  13. package/dist/agent-src/rules/session-canary.md +2 -0
  14. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  15. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  16. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  17. package/dist/agent-src/rules/user-interaction.md +3 -2
  18. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  21. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +94 -23
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +4 -4
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +11 -8
  28. package/dist/discovery/trust-report.md +3 -3
  29. package/dist/discovery/workspaces.json +9 -6
  30. package/dist/hooks/dispatch.js +355 -63
  31. package/dist/mcp/registry-manifest.json +3 -3
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/router.json +1 -1
  34. package/dist/server/nicknameResolver.js +116 -0
  35. package/dist/server/nicknameResolver.js.map +1 -0
  36. package/dist/server/routes/ping.js +29 -11
  37. package/dist/server/routes/ping.js.map +1 -1
  38. package/dist/shared/interactiveContext.js +73 -0
  39. package/dist/shared/interactiveContext.js.map +1 -0
  40. package/dist/shared/nicknamePrefill.js +59 -0
  41. package/dist/shared/nicknamePrefill.js.map +1 -0
  42. package/dist/shared/settingsAsks.js +128 -0
  43. package/dist/shared/settingsAsks.js.map +1 -0
  44. package/dist/shared/settingsConsent.js +93 -0
  45. package/dist/shared/settingsConsent.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/config/agents-paths.yml +15 -0
  48. package/src/config/gate-coverage.yml +29 -1
  49. package/src/config/gate-violation-baselines.json +13 -7
  50. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  51. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  52. package/src/scripts/ai-video/stitch.sh +27 -9
  53. package/src/scripts/before_complete_hook.ts +23 -4
  54. package/src/scripts/bench_ab_v2_run.ts +272 -16
  55. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  56. package/src/scripts/check_completion_review.ts +80 -3
  57. package/src/scripts/check_corpus_staleness.ts +692 -0
  58. package/src/scripts/check_gate_coverage.ts +67 -0
  59. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  60. package/src/scripts/conformance_scan.ts +47 -17
  61. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  62. package/src/scripts/git_authorization_hook.ts +75 -10
  63. package/src/scripts/hook_manifest.yaml +19 -0
  64. package/src/scripts/hooks/block_no_verify.ts +3 -1
  65. package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
  66. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  67. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  68. package/src/scripts/hooks/evidence_independence.ts +40 -6
  69. package/src/scripts/language_mirror_hook.ts +200 -11
  70. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  71. package/src/scripts/lint_agents_layout.ts +2 -0
  72. package/src/scripts/lint_hook_manifest.ts +29 -0
  73. package/src/scripts/memory_learn_hook.ts +109 -6
  74. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  75. package/src/scripts/release.ts +38 -1
  76. package/src/scripts/schemas/review-findings.schema.json +99 -0
  77. package/src/shared/interactiveContext.ts +98 -0
  78. package/src/shared/nicknamePrefill.ts +96 -0
  79. package/src/shared/settingsAsks.ts +176 -0
  80. package/src/shared/settingsConsent.ts +136 -0
@@ -95,6 +95,25 @@ interface ArmSpec {
95
95
  setting_sources: string | null;
96
96
  inject: string | null; // None | "rdp" | "placebo"
97
97
  recursive?: boolean; // ADR-106 D₂ arm: depth-bounded attempt→critic→re-attempt loop
98
+ /**
99
+ * Per-arm override for the footprint-lift audit, or `null` to declare that
100
+ * lift is **not a valid signal** for this arm.
101
+ *
102
+ * `audit_activation`'s cross-arm direction catches a treatment surface that
103
+ * COLLAPSED to baseline, by requiring the arm's prompt footprint to sit at
104
+ * least `ACTIVATION_MIN_LIFT_RATIO` above the paired vanilla run. That test
105
+ * presupposes a treatment surface large enough to move the footprint. An arm
106
+ * whose treatment is deliberately *minimal* — `bare-principle` is one
107
+ * sentence — has no lift to detect, so including it would fail legitimate
108
+ * runs: the exact failure the ratio's own calibration note warns about.
109
+ *
110
+ * `null` narrows the audit for that arm to the per-trial TEXT direction,
111
+ * which is checked both ways (`activation_verdict`: a text arm must carry an
112
+ * injection, a non-text arm must not). It never leaves the arm unaudited —
113
+ * and `bench_ab_v2_run.test.ts` pins both halves, so neither an
114
+ * always-firing nor a never-firing audit passes.
115
+ */
116
+ min_lift_ratio?: number | null;
98
117
  }
99
118
 
100
119
  // Arm -> (setting_sources, inject) where inject ∈ {None, "rdp", "placebo"}.
@@ -118,6 +137,28 @@ interface ArmSpec {
118
137
  // shipped `balanced` router profile (which does NOT include downstream-changes
119
138
  // — tier_2 — so this arm also tests whether that profile cut loses the lift).
120
139
  // Both are opt-in: NOT in the default arm list; existing runs unchanged.
140
+ //
141
+ // `package-ladder` / `bare-principle` (road-to-solution-minimalism Phase 3 — the
142
+ // two arms its Arms step names and `ARMS` lacked). Also opt-in, so existing runs
143
+ // and golden-parity outputs are unchanged.
144
+ //
145
+ // `package-ladder` is NOT redundant with `package`, and the reason is measurable
146
+ // rather than stylistic: the ladder ships inside `improve-before-implement`,
147
+ // which is `type: auto`, `tier: 2b`, `alwaysApply: false`, keyword-triggered on
148
+ // `refactor|implement|migration`, and is absent from `dist/router.json`'s
149
+ // preloaded tier lists. So under `package` the ladder reaches the model only when
150
+ // a task's own wording happens to trip one of those keywords — unmeasured, and
151
+ // per-task. That is finding F1's failure class exactly (an activation gap that a
152
+ // null cannot be distinguished from), which is why the pair is: `package` = the
153
+ // shipped reality, `package-ladder` = the same config with the ladder rule body
154
+ // injected via sysprompt so it is guaranteed in context. The contrast measures
155
+ // the ladder, not the trigger set.
156
+ //
157
+ // `bare-principle` is finding F6's control: the naked principle with NO floors
158
+ // routed, plugin scoped away. Its text is authored HERE and is deliberately not
159
+ // the external source's own prompt — per `code-provenance`, a borrowed prompt is
160
+ // re-derived rather than copied, and the arm's job is to be a floor-free
161
+ // principle statement, not a specific artefact.
121
162
  export const ARMS: Record<string, ArmSpec> = {
122
163
  vanilla: { setting_sources: 'project,local', inject: null },
123
164
  package: { setting_sources: null, inject: null },
@@ -128,6 +169,8 @@ export const ARMS: Record<string, ArmSpec> = {
128
169
  'hardened-placebo': { setting_sources: null, inject: 'hardened-placebo' },
129
170
  'rules-kernel-dc': { setting_sources: 'project,local', inject: 'rules-kernel-dc' },
130
171
  'rules-balanced': { setting_sources: 'project,local', inject: 'rules-balanced' },
172
+ 'package-ladder': { setting_sources: null, inject: 'ladder' },
173
+ 'bare-principle': { setting_sources: 'project,local', inject: 'bare-principle', min_lift_ratio: null },
131
174
  };
132
175
 
133
176
  /**
@@ -202,7 +245,71 @@ export function rules_subset_text(subset: 'rules-kernel-dc' | 'rules-balanced'):
202
245
  return bodies.join('\n\n---\n\n');
203
246
  }
204
247
 
248
+ /**
249
+ * The `bare-principle` arm's whole treatment — finding F6's control.
250
+ *
251
+ * One sentence, no floors, no routing, no ladder rungs. It exists so the
252
+ * benchmark can separate "the principle" from "the principle with this package's
253
+ * safety floors routed behind it": F6 measured a bare critic prompt nearly
254
+ * matching a full minimalism artefact on size *while being the only arm that
255
+ * dropped a safety guard*, so a report without this arm asserts the floors'
256
+ * contribution instead of measuring it.
257
+ *
258
+ * Authored here on purpose. The external source's own prompt is not reproduced —
259
+ * `code-provenance` requires a conscious borrow to be re-derived rather than
260
+ * copied, and nothing about the arm's function depends on its exact wording.
261
+ * Deterministic (no interpolation) so the injected bytes are stable per checkout.
262
+ */
263
+ export function bare_principle_text(): string {
264
+ return 'Prefer the smallest solution that fully solves the stated problem; do not add what was not asked for.';
265
+ }
266
+
267
+ /**
268
+ * The ladder rule body, read from the projection — the `package-ladder` arm.
269
+ *
270
+ * Reads `dist/agent-src/rules/improve-before-implement.md`, the same source
271
+ * `rules_subset_text` uses, so the injected text is exactly the rule the package
272
+ * would load if its keyword triggers fired. Reading the projection rather than
273
+ * restating the ladder keeps the arm honest: a hand-copied summary would measure
274
+ * the summary.
275
+ */
276
+ export function ladder_rule_text(): string {
277
+ return fs.readFileSync(path.join(RULES_DIR, 'improve-before-implement.md'), 'utf-8').trim();
278
+ }
279
+
280
+ /**
281
+ * Which arms the cross-arm FOOTPRINT-lift direction of the activation audit
282
+ * applies to.
283
+ *
284
+ * Three exclusions, each for a different reason:
285
+ * - `vanilla` is the baseline the ratio is measured against.
286
+ * - an arm with no treatment surface (`expected_injection === 'none'`) has no
287
+ * lift to require.
288
+ * - an arm declaring `min_lift_ratio: null` has a treatment too small for lift to
289
+ * be a signal at all (see `ArmSpec.min_lift_ratio`); requiring one there fails
290
+ * legitimate runs. Its per-trial TEXT direction is unaffected and still runs
291
+ * both ways.
292
+ *
293
+ * Pure and exported so the exclusion set is asserted rather than trusted — a
294
+ * silent widening of it would be a reach reduction in the audit.
295
+ */
296
+ export function lift_audit_arms(arms: readonly string[]): string[] {
297
+ return arms.filter((a) => {
298
+ const spec = ARMS[a] as ArmSpec | undefined;
299
+ if (!spec || a === 'vanilla') {
300
+ return false;
301
+ }
302
+ return expected_injection(spec) !== 'none' && spec.min_lift_ratio !== null;
303
+ });
304
+ }
305
+
205
306
  export function injected_text(inject: string | null, placebo_chars: number): string | null {
307
+ if (inject === 'bare-principle') {
308
+ return bare_principle_text();
309
+ }
310
+ if (inject === 'ladder') {
311
+ return ladder_rule_text();
312
+ }
206
313
  if (inject === 'rdp') {
207
314
  return v1.system_prompt_for('with-rdp');
208
315
  }
@@ -222,10 +329,38 @@ export function injected_text(inject: string | null, placebo_chars: number): str
222
329
  return null;
223
330
  }
224
331
 
225
- /** Copy the task's pristine fixture into a throwaway working clone. */
226
- export function reset_fixture(task: Dict): [string, string] {
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] {
227
362
  const fixture = path.join(FIXTURES_ROOT, String(task['fixture']));
228
- const dest = path.join(WORK_ROOT, String(task['id']));
363
+ const dest = workspace_dir(String(task['id']), arm, seed);
229
364
  if (fs.existsSync(dest)) {
230
365
  fs.rmSync(dest, { recursive: true, force: true });
231
366
  }
@@ -277,6 +412,13 @@ interface RunOneOpts {
277
412
  sp_dir: string;
278
413
  max_depth?: number | null; // recursion arm only — hard cap on correction rounds (default 1)
279
414
  host?: string; // 'claude' (default) | 'codex' — P2 non-Claude replication (ADR-110 roadmap Phase 4)
415
+ /**
416
+ * Trial seed — delta #7. Part of the workspace key, so each (task, arm, seed)
417
+ * gets its own preserved clone. Defaults to 0 so an existing caller that does
418
+ * not thread it still gets a stable, arm-scoped directory rather than the old
419
+ * task-only collision.
420
+ */
421
+ seed?: number;
280
422
  }
281
423
 
282
424
  // ── codex host adapter (P2 non-Claude weak-host replication) ────────────────
@@ -302,6 +444,10 @@ export const CODEX_VALID_ARMS: readonly string[] = [
302
444
  'placebo',
303
445
  'rules-kernel-dc',
304
446
  'rules-balanced',
447
+ // Pure-injection, so it carries to a non-Claude host unchanged. Its
448
+ // plugin-bearing sibling `package-ladder` deliberately is NOT here — it
449
+ // depends on the real plugin, which this host cannot load.
450
+ 'bare-principle',
305
451
  ];
306
452
 
307
453
  export function codex_executable(): string | null {
@@ -505,7 +651,7 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
505
651
  return run_one_recursive(task, opts);
506
652
  }
507
653
  const host = opts.host ?? 'claude';
508
- const [clone, fixture] = reset_fixture(task);
654
+ const [clone, fixture] = reset_fixture(task, arm, opts.seed ?? 0);
509
655
  const sp_text = injected_text(spec.inject, opts.placebo_chars);
510
656
  let run: Dict;
511
657
  if (host === 'codex') {
@@ -539,6 +685,10 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
539
685
  discipline_pass: score.discipline_pass,
540
686
  metrics: trajectory_metrics(run, score),
541
687
  injected_chars: sp_text ? sp_text.length : 0,
688
+ // Delta #7 — where this trial's evidence lives. Recorded on the trial
689
+ // rather than recomputed later, so an offline re-scorer reads the path the
690
+ // sweep actually used instead of re-deriving a key that may have changed.
691
+ workspace: clone,
542
692
  ...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
543
693
  };
544
694
  }
@@ -567,6 +717,96 @@ export interface RecursiveAttempt {
567
717
  * threading). Whether recursion actually lifts capability/discipline is the
568
718
  * empirical question the live `bench:ab` run answers — not asserted here.
569
719
  */
720
+ // ── delta #8 — the no-network selftest ──────────────────────────────────────
721
+ //
722
+ // `--mode dry-run` printed a run count and returned before the CLI check, the
723
+ // fixtures, the scorer, the activation audit and the report writer — so it could
724
+ // not tell a working harness from a broken one. The roadmap's Reproducibility
725
+ // step asks for a `--selftest` that "runs green with no network and no key", and
726
+ // a mode that exercises nothing cannot answer that.
727
+ //
728
+ // `--mode selftest` substitutes EXACTLY ONE thing: the model call. Fixture
729
+ // cloning, the deterministic scorer, the per-trial activation stamp, the
730
+ // cross-arm audit, the report writer and every exit code run for real. What it
731
+ // therefore proves is the harness, not the hypothesis — and that is the whole
732
+ // distinction between a self-test and a measurement.
733
+
734
+ /**
735
+ * Deterministic synthetic usage for one selftest trial.
736
+ *
737
+ * Derived from the arm's real treatment size rather than invented, so the
738
+ * footprint relationships the activation audit reads are the ones the arm
739
+ * actually implies: a plugin-bearing arm shows the package's footprint, a text arm
740
+ * shows its injection, and `vanilla` shows neither.
741
+ *
742
+ * The plugin figure is a stand-in for a surface this process cannot observe
743
+ * without the real CLI. It is deliberately far above `ACTIVATION_MIN_LIFT_RATIO`
744
+ * so a selftest failure means the wiring is broken, never that a synthetic
745
+ * constant drifted under a threshold.
746
+ */
747
+ export function selftest_usage(arm: string, seed: number, placebo_chars: number): TokensBreakdown {
748
+ const spec = ARMS[arm] as ArmSpec | undefined;
749
+ const injected = spec ? (injected_text(spec.inject, placebo_chars)?.length ?? 0) : 0;
750
+ const plugin = spec && spec.setting_sources === null ? 4000 : 0;
751
+ return {
752
+ input_tokens: 1000 + plugin + Math.floor(injected / 4),
753
+ output_tokens: 200 + seed,
754
+ cache_read_input_tokens: 0,
755
+ cache_creation_input_tokens: 0,
756
+ };
757
+ }
758
+
759
+ /**
760
+ * One selftest trial — the real pipeline around a synthetic model call.
761
+ *
762
+ * `usage_fn` is injectable so a test can force the failure directions (a
763
+ * collapsed footprint, a model mismatch) and assert that the audit still fires.
764
+ * Without that seam the selftest could only ever be observed passing, which is
765
+ * the "gate that scans nothing" shape this repo's own discipline warns about.
766
+ */
767
+ export function selftest_run(
768
+ task: Dict,
769
+ arm: string,
770
+ opts: RunOneOpts,
771
+ usage_fn: (arm: string, seed: number, placebo_chars: number) => TokensBreakdown = selftest_usage,
772
+ ): Dict {
773
+ const spec = ARMS[arm] as ArmSpec;
774
+ const seed = opts.seed ?? 0;
775
+ // Real fixture clone — this is also what exercises delta #7's per-trial key.
776
+ const [clone, fixture] = reset_fixture(task, arm, seed);
777
+ const sp_text = injected_text(spec.inject, opts.placebo_chars);
778
+ const run: Dict = {
779
+ errored: false,
780
+ reason: null,
781
+ transcript: `selftest transcript · ${String(task['id'])} · ${arm} · seed ${seed}`,
782
+ num_turns: 1,
783
+ wall_time_seconds: 0,
784
+ tokens: 0,
785
+ tokens_breakdown: usage_fn(arm, seed, opts.placebo_chars),
786
+ // The synthetic envelope bills exactly the pinned model, so `model_check`
787
+ // passes for a healthy run and fails if the pin is ever dropped.
788
+ models_seen: opts.model ? [opts.model] : [],
789
+ };
790
+ // Real scorer — no model call, reads the fixture and the clone off disk.
791
+ const score = scoring.score_task_v2(task, {
792
+ fixture_root: fixture,
793
+ clone_root: clone,
794
+ transcript: String(run['transcript'] ?? ''),
795
+ });
796
+ return {
797
+ errored: false,
798
+ reason: null,
799
+ synthetic: true,
800
+ capability_pass: score.capability_pass,
801
+ discipline_score: new PyFloat(score.discipline_score),
802
+ discipline_pass: score.discipline_pass,
803
+ metrics: trajectory_metrics(run, score),
804
+ injected_chars: sp_text ? sp_text.length : 0,
805
+ workspace: clone,
806
+ ...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
807
+ };
808
+ }
809
+
570
810
  export function run_one_recursive(
571
811
  task: Dict,
572
812
  opts: RunOneOpts,
@@ -577,7 +817,9 @@ export function run_one_recursive(
577
817
  const makeAttempt =
578
818
  attemptFn ??
579
819
  ((depth: number, priorVerdict: string | null): RecursiveAttempt => {
580
- const [clone, fixture] = reset_fixture(task);
820
+ // Each correction round gets its own preserved workspace too — a
821
+ // depth-2 attempt must not overwrite the depth-1 evidence.
822
+ const [clone, fixture] = reset_fixture(task, `package-recursive-d${depth}`, opts.seed ?? 0);
581
823
  let sp_file: string | null = null;
582
824
  if (priorVerdict) {
583
825
  sp_file = path.join(opts.sp_dir, `.sp-recursive-${depth}.txt`);
@@ -941,7 +1183,11 @@ export function main(argv: string[] | null = null): number {
941
1183
  return 0;
942
1184
  }
943
1185
 
944
- if (v1.claude_executable() === null) {
1186
+ const selftest = args.mode === 'selftest';
1187
+
1188
+ // The selftest exists to run with no network and no key, so requiring the
1189
+ // host CLI here would defeat it. Every other gate below still applies.
1190
+ if (!selftest && v1.claude_executable() === null) {
945
1191
  process.stderr.write('claude CLI not found\n');
946
1192
  return 1;
947
1193
  }
@@ -988,15 +1234,18 @@ export function main(argv: string[] | null = null): number {
988
1234
  tasks,
989
1235
  arms,
990
1236
  args.seeds,
991
- (task, _arm, _seed) =>
992
- run_one(task, _arm, {
1237
+ (task, _arm, _seed) => {
1238
+ const runOpts: RunOneOpts = {
993
1239
  model: args.model,
994
1240
  max_budget,
995
1241
  timeout: args.timeout,
996
1242
  placebo_chars,
997
1243
  sp_dir,
998
1244
  host: args.host,
999
- }),
1245
+ seed: _seed,
1246
+ };
1247
+ return selftest ? selftest_run(task, _arm, runOpts) : run_one(task, _arm, runOpts);
1248
+ },
1000
1249
  ckpt,
1001
1250
  undefined,
1002
1251
  (r) => budget.add(r['tokens_breakdown'] as TokensBreakdown | undefined),
@@ -1006,8 +1255,7 @@ export function main(argv: string[] | null = null): number {
1006
1255
  // the paired plugin direction needs the whole record set, so it cannot be a
1007
1256
  // per-trial check alone. `vanilla` is the baseline; every arm that is
1008
1257
  // supposed to carry a surface the baseline lacks is a lift arm.
1009
- const lift_arms = arms.filter((a) => a !== 'vanilla' && expected_injection(ARMS[a] as ArmSpec) !== 'none');
1010
- const audit = audit_activation(records, { baseline_arm: 'vanilla', lift_arms });
1258
+ const audit = audit_activation(records, { baseline_arm: 'vanilla', lift_arms: lift_audit_arms(arms) });
1011
1259
 
1012
1260
  const stamp = v1.utc_stamp();
1013
1261
  const payload: Dict = {
@@ -1022,6 +1270,12 @@ export function main(argv: string[] | null = null): number {
1022
1270
  placebo_chars,
1023
1271
  corpus: 'ab-trackb-v2',
1024
1272
  host: args.host,
1273
+ // Hygiene (F4/F7): a selftest report must be unmistakable for a measured
1274
+ // one. Both the tier marker and the filename say so, because a number is
1275
+ // only ever allowed to render from a report and this report's numbers are
1276
+ // synthetic by construction.
1277
+ tier: selftest ? 'selftest' : 'live',
1278
+ synthetic: selftest,
1025
1279
  activation_audit: {
1026
1280
  checked: audit.checked,
1027
1281
  skipped: audit.skipped,
@@ -1030,7 +1284,7 @@ export function main(argv: string[] | null = null): number {
1030
1284
  aborted: aborted ?? null,
1031
1285
  records,
1032
1286
  };
1033
- const out = path.join(REPORTS_DIR, `${stamp}-ab-v2-paired.json`);
1287
+ const out = path.join(REPORTS_DIR, `${stamp}-ab-v2-paired${selftest ? '-selftest' : ''}.json`);
1034
1288
  fs.writeFileSync(out, _jsonDumps(_toJson(payload), 2) + '\n');
1035
1289
  // The paired report now holds everything — retire the checkpoint so a
1036
1290
  // finished sweep leaves no residue and a later identical sweep starts fresh.
@@ -1039,7 +1293,9 @@ export function main(argv: string[] | null = null): number {
1039
1293
  }
1040
1294
  process.stdout.write(
1041
1295
  `bench_ab_v2: wrote ${_relToRootPosix(out)} ` +
1042
- `(${records.length} tasks, ${total} runs, spend $${budget.spent_usd.toFixed(2)})\n`,
1296
+ `(${records.length} tasks, ${total} runs, ` +
1297
+ (selftest ? 'SELFTEST — synthetic runs, no spend, not publishable' : `spend $${budget.spent_usd.toFixed(2)}`) +
1298
+ `)\n`,
1043
1299
  );
1044
1300
 
1045
1301
  // The report is always written — an aborted or integrity-failed sweep still
@@ -1102,7 +1358,7 @@ function parse_args(argv: string[]): ParsedArgs {
1102
1358
  checkpoint: true,
1103
1359
  fresh: false,
1104
1360
  };
1105
- const usage = `usage: ${prog} [-h] [--arms ARMS] [--seeds SEEDS] [--tasks TASKS] [--limit LIMIT] [--model MODEL] [--budget BUDGET] [--max-usd MAX_USD] [--timeout TIMEOUT] [--mode {live,dry-run}] [--host {claude,codex}] [--no-checkpoint] [--fresh]\n`;
1361
+ const usage = `usage: ${prog} [-h] [--arms ARMS] [--seeds SEEDS] [--tasks TASKS] [--limit LIMIT] [--model MODEL] [--budget BUDGET] [--max-usd MAX_USD] [--timeout TIMEOUT] [--mode {live,dry-run,selftest}] [--host {claude,codex}] [--no-checkpoint] [--fresh]\n`;
1106
1362
  const argErr = (msg: string): never => {
1107
1363
  process.stderr.write(usage);
1108
1364
  process.stderr.write(`${prog}: error: ${msg}\n`);
@@ -1161,7 +1417,7 @@ function parse_args(argv: string[]): ParsedArgs {
1161
1417
  } else if (a.startsWith('--timeout=')) {
1162
1418
  out.timeout = _pyInt(a.slice('--timeout='.length), '--timeout');
1163
1419
  } else if (a === '--mode') {
1164
- out.mode = _choice(need(i, '--mode'), '--mode', ['live', 'dry-run'], argErr);
1420
+ out.mode = _choice(need(i, '--mode'), '--mode', ['live', 'dry-run', 'selftest'], argErr);
1165
1421
  i += 1;
1166
1422
  } else if (a === '--host') {
1167
1423
  out.host = _choice(need(i, '--host'), '--host', ['claude', 'codex'], argErr);
@@ -1169,7 +1425,7 @@ function parse_args(argv: string[]): ParsedArgs {
1169
1425
  } else if (a.startsWith('--host=')) {
1170
1426
  out.host = _choice(a.slice('--host='.length), '--host', ['claude', 'codex'], argErr);
1171
1427
  } else if (a.startsWith('--mode=')) {
1172
- out.mode = _choice(a.slice('--mode='.length), '--mode', ['live', 'dry-run'], argErr);
1428
+ out.mode = _choice(a.slice('--mode='.length), '--mode', ['live', 'dry-run', 'selftest'], argErr);
1173
1429
  } else if (a === '--no-checkpoint') {
1174
1430
  out.checkpoint = false;
1175
1431
  } else if (a === '--fresh') {
@@ -4,9 +4,16 @@
4
4
  *
5
5
  * Ported from the retired Python `src/scripts/bench_ab_v2_stats.py` (ADR-200 Python→TS
6
6
  * migration). Mirrors the CLI contract EXACTLY: positional `report` arg, the
7
- * `--json` / `--markdown PATH` flags, exit codes (0 ok / 1 no report found),
8
- * byte-identical stdout/stderr, byte-identical analysis JSON, and byte-identical
9
- * rendered markdown. No behaviour changes.
7
+ * `--json` / `--markdown PATH` flags, exit codes (0 ok / 1 no report found), and
8
+ * byte-identical stdout/stderr.
9
+ *
10
+ * The port itself introduced no behaviour change. Since then ONE additive change
11
+ * has landed, named here so the paragraph above stops reading as a standing
12
+ * promise it no longer keeps: S0.3 **delta #6** adds a `cost` block to the
13
+ * analysis JSON and a `Table 3b` to the rendered markdown (see `cost_by_arm`).
14
+ * Both are additions — every pre-existing key and table is untouched, and
15
+ * consumers that read named keys (`render_benchmark_composite`,
16
+ * `render_benchmark_md`, `bench_ab_diff`) are unaffected.
10
17
  *
11
18
  * Reads a v2 paired report (bench_ab_v2_run.py output) and computes, for each
12
19
  * arm comparison, paired significance + effect size on:
@@ -29,11 +36,15 @@ import * as path from 'node:path';
29
36
  import process from 'node:process';
30
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
38
 
39
+ import { cost_usd, tier_for_model, type TierRates } from './_lib/bench_ab_activation.js';
40
+ import { load_pricing } from './_lib/bench_cost.js';
41
+
32
42
  const _HERE = fileURLToPath(import.meta.url);
33
43
 
34
44
  // bench_ab_v2_stats.ts → parents[2] is repo root (script lives in src/scripts/).
35
45
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
36
46
  const REPORTS_DIR = path.join(REPO_ROOT, 'internal', 'bench', 'reports', 'ab-v2');
47
+ const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
37
48
 
38
49
  type Dict = Record<string, unknown>;
39
50
 
@@ -442,6 +453,89 @@ export function mean_tokens_by_arm(records: Dict[], arms: string[]): Dict {
442
453
  return out;
443
454
  }
444
455
 
456
+ // ── delta #6 — the cost sheet ───────────────────────────────────────────────
457
+ //
458
+ // Table 3 reports mean TOKENS, which is not a cost: the four usage buckets differ
459
+ // in price by up to 125×, so a run that is mostly cache-read and a run that is
460
+ // mostly output can share a token total and differ in dollars by two orders of
461
+ // magnitude. S0.3's own estimate carries a ~10× spread on the treatment arm for
462
+ // exactly this reason, and it names closing that spread as this delta.
463
+ //
464
+ // The inputs already exist and are reused rather than rebuilt: `tokens_breakdown`
465
+ // on every trial (delta #2), `cost_usd` which prices the buckets separately, and
466
+ // `tier_for_model` (both delta #4). What was missing was wiring them into the
467
+ // REPORT, so the cost axis was priceable per run and unpriced per arm.
468
+
469
+ /** Per-arm cost, priced bucket-by-bucket, plus the provenance of the prices. */
470
+ export function cost_by_arm(records: Dict[], arms: string[], model: string | null, pricingPath: string): Dict {
471
+ const [rates, sourced_on] = load_pricing(pricingPath);
472
+ const tier = model ? tier_for_model(model) : null;
473
+ const tier_rates: TierRates | null = tier && rates[tier] ? (rates[tier] as TierRates) : null;
474
+
475
+ const per_arm: Dict = {};
476
+ for (const arm of arms) {
477
+ let n = 0;
478
+ let total = 0;
479
+ const buckets = { input: 0, output: 0, cache_write: 0, cache_read: 0 };
480
+ for (const rec of records) {
481
+ const armRuns = _dictOr(rec['arms'])[arm];
482
+ for (const r of Array.isArray(armRuns) ? (armRuns as Dict[]) : []) {
483
+ // Same exclusion as the token axis: an errored or truncated run's
484
+ // usage is capped by the budget, not representative of the work.
485
+ if (_pyTruthy(r['errored'])) {
486
+ continue;
487
+ }
488
+ n += 1;
489
+ const b = (r['tokens_breakdown'] ?? {}) as Record<string, unknown>;
490
+ buckets.input += _orZero(b['input_tokens']);
491
+ buckets.output += _orZero(b['output_tokens']);
492
+ buckets.cache_write += _orZero(b['cache_creation_input_tokens']);
493
+ buckets.cache_read += _orZero(b['cache_read_input_tokens']);
494
+ if (tier_rates) {
495
+ total += cost_usd(b as never, tier_rates);
496
+ }
497
+ }
498
+ }
499
+ per_arm[arm] = {
500
+ n,
501
+ // `null` rather than 0 when the model is unpriceable: a zero would
502
+ // read as "this arm was free", which is a different claim from "we
503
+ // cannot price it". pricing.yaml's own contract says surface the gap.
504
+ total_usd: tier_rates ? PF(_pyRound(total, 4)) : null,
505
+ mean_usd: tier_rates && n ? PF(_pyRound(total / n, 4)) : null,
506
+ tokens_by_bucket: buckets,
507
+ };
508
+ }
509
+
510
+ return {
511
+ tier: tier ?? 'unknown',
512
+ priced: tier_rates !== null,
513
+ pricing_sourced_on: sourced_on,
514
+ per_arm,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Whole days between the price sourcing date and the report's own stamp.
520
+ *
521
+ * Measured against the STAMP, not against today: a report is a fixed artefact, so
522
+ * re-rendering it must not change the number. Returns null when either date is
523
+ * unreadable — an invented age would be worse than an absent one.
524
+ */
525
+ export function pricing_age_days(sourced_on: string | null, stamp: string | null): number | null {
526
+ if (!sourced_on || !stamp) {
527
+ return null;
528
+ }
529
+ const src = Date.parse(sourced_on.slice(0, 10));
530
+ // Report stamps are `YYYY-MM-DDTHH-MM-SSZ` — the time separators are dashes,
531
+ // so only the date half is parseable without rewriting it.
532
+ const rep = Date.parse(stamp.slice(0, 10));
533
+ if (Number.isNaN(src) || Number.isNaN(rep)) {
534
+ return null;
535
+ }
536
+ return Math.floor((rep - src) / 86_400_000);
537
+ }
538
+
445
539
  export function bucket_rates(records: Dict[], arms: string[]): Dict {
446
540
  const out: Dict = {};
447
541
  for (const arm of arms) {
@@ -482,6 +576,7 @@ export function analyse(payload: Dict): Dict {
482
576
  comparisons: comps,
483
577
  status_buckets: bucket_rates(records, arms),
484
578
  mean_tokens: mean_tokens_by_arm(records, arms),
579
+ cost: cost_by_arm(records, arms, payload['model'] ? String(payload['model']) : null, PRICING_PATH),
485
580
  };
486
581
  }
487
582
 
@@ -640,6 +735,50 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
640
735
  L.push('|---|---|---|---|');
641
736
  L.push(`| mean tokens | ${_thousands(tb)} | ${_thousands(tt)} | ${_thousandsSigned(tt - tb)} |`);
642
737
  L.push('');
738
+
739
+ // Delta #6 — the dollar half of the cost axis. Tokens above are a volume;
740
+ // these are a price, and the two rank arms differently because the four
741
+ // usage buckets differ in cost by up to 125×.
742
+ const cost = _dictOr(a['cost']);
743
+ const costArms = _dictOr(cost['per_arm']);
744
+ const cb = _dictOr(costArms[String(cmp['arm_baseline'])]);
745
+ const ct = _dictOr(costArms[String(cmp['arm_treatment'])]);
746
+ L.push('### Table 3b — cost axis in dollars (bucket-priced, non-errored)');
747
+ L.push('');
748
+ if (!_pyTruthy(cost['priced'])) {
749
+ L.push(
750
+ `> **Unpriced.** No pricing row matches model \`${_pyStr(a['model'])}\` ` +
751
+ `(tier \`${_pyStr(cost['tier'])}\`), so no dollar figure is shown. This is a ` +
752
+ 'gap, not a zero — add the row to `internal/bench/pricing.yaml` rather than ' +
753
+ 'reading the absence as "free".',
754
+ );
755
+ } else {
756
+ L.push('| metric | baseline | treatment | Δ |');
757
+ L.push('|---|---|---|---|');
758
+ L.push(
759
+ `| mean USD/run | ${_usd(cb['mean_usd'])} | ${_usd(ct['mean_usd'])} ` +
760
+ `| ${_usdSigned(_pyFloat(ct['mean_usd']) - _pyFloat(cb['mean_usd']))} |`,
761
+ );
762
+ L.push(
763
+ `| total USD | ${_usd(cb['total_usd'])} | ${_usd(ct['total_usd'])} ` +
764
+ `| ${_usdSigned(_pyFloat(ct['total_usd']) - _pyFloat(cb['total_usd']))} |`,
765
+ );
766
+ L.push('');
767
+ const age = pricing_age_days(
768
+ cost['pricing_sourced_on'] ? String(cost['pricing_sourced_on']) : null,
769
+ a['stamp'] ? String(a['stamp']) : null,
770
+ );
771
+ const src = cost['pricing_sourced_on'] ? String(cost['pricing_sourced_on']) : 'unknown';
772
+ L.push(
773
+ `> Priced per bucket (input / output / cache-write / cache-read) at the ` +
774
+ `\`${_pyStr(cost['tier'])}\` tier, sourced ${src}` +
775
+ (age === null ? '' : ` — **${age} days before this report**`) +
776
+ '. A blended rate over the token total is not an approximation of this, it is ' +
777
+ 'a different number. Stale prices make these figures stale: re-source them ' +
778
+ 'before quoting a dollar amount.',
779
+ );
780
+ }
781
+ L.push('');
643
782
  const at = _dictOr(cmp['attrition']);
644
783
  L.push('### Table 4 — attrition (dropped pairs are not missing-at-random)');
645
784
  L.push('');
@@ -738,6 +877,20 @@ function _thousandsSigned(n: number): string {
738
877
  return t < 0 ? body : `+${body}`;
739
878
  }
740
879
 
880
+ /**
881
+ * Render a cost cell — `n/a` when the model could not be priced, never `$0.00`.
882
+ *
883
+ * Unwraps through `_pyFloat` because the analysis carries costs as `PyFloat`
884
+ * (a plain `{value}` wrapper with no `valueOf`), so `Number(cell)` yields NaN.
885
+ */
886
+ function _usd(v: unknown): string {
887
+ return v === null || v === undefined ? 'n/a' : `$${_pyFloat(v).toFixed(4)}`;
888
+ }
889
+
890
+ function _usdSigned(n: number): string {
891
+ return `${n >= 0 ? '+' : '-'}$${Math.abs(n).toFixed(4)}`;
892
+ }
893
+
741
894
  /** `mt.get(arm, {}).get("mean_tokens", 0)`. */
742
895
  function _meanTokens(arm: unknown): number {
743
896
  const d = _dictOr(arm);