@event4u/agent-config 9.20.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.
Files changed (239) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +86 -0
  3. package/dist/agent-src/commands/worktree/cleanup.md +17 -0
  4. package/dist/agent-src/commands/worktree/status.md +6 -0
  5. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli-delegate/{chunk-XGA2NTU4.js → chunk-3EWYC3YK.js} +40 -0
  8. package/dist/cli-delegate/cmd_handoff.js +1 -1
  9. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  10. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  11. package/dist/discovery/deprecation-report.md +1 -1
  12. package/dist/discovery/discovery-manifest.json +5 -5
  13. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  14. package/dist/discovery/discovery-manifest.summary.md +1 -1
  15. package/dist/discovery/orphan-report.md +1 -1
  16. package/dist/discovery/packs.json +2 -2
  17. package/dist/discovery/trust-report.md +1 -1
  18. package/dist/discovery/workspaces.json +2 -2
  19. package/dist/hooks/dispatch.js +58 -2
  20. package/dist/mcp/registry-manifest.json +2 -2
  21. package/dist/mcp/server.json +2 -2
  22. package/dist/mcp/server.mjs +47 -29
  23. package/package.json +1 -1
  24. package/src/config/gate-coverage.yml +22 -4
  25. package/src/config/gate-violation-baselines.json +3 -8
  26. package/src/scripts/_lib/bench_ab_activation.ts +368 -0
  27. package/src/scripts/_lib/gate_population.ts +70 -0
  28. package/src/scripts/_lib/roadmap_granularity.ts +157 -0
  29. package/src/scripts/_lib/scan_scope.ts +44 -0
  30. package/src/scripts/_lib/stdin.ts +82 -0
  31. package/src/scripts/audit_adr_coverage.ts +38 -0
  32. package/src/scripts/audit_auto_rules.ts +17 -3
  33. package/src/scripts/audit_command_surface.ts +51 -0
  34. package/src/scripts/audit_initial_context.ts +39 -3
  35. package/src/scripts/audit_mcp_tools.ts +25 -0
  36. package/src/scripts/audit_overlap.ts +24 -1
  37. package/src/scripts/audit_skill_descriptions.ts +19 -0
  38. package/src/scripts/{verify_before_complete_hook.ts → before_complete_hook.ts} +16 -3
  39. package/src/scripts/bench_ab_task_runner.ts +18 -0
  40. package/src/scripts/bench_ab_v2_run.ts +147 -6
  41. package/src/scripts/bench_ab_v2_stats.ts +74 -2
  42. package/src/scripts/check_adr_frontmatter.ts +23 -3
  43. package/src/scripts/check_always_budget.ts +24 -0
  44. package/src/scripts/check_artefact_checksums.ts +24 -1
  45. package/src/scripts/check_artefact_count_messaging.ts +20 -0
  46. package/src/scripts/check_augmentignore.ts +22 -15
  47. package/src/scripts/check_backstop_debt.ts +22 -0
  48. package/src/scripts/check_beta_review_markers.ts +22 -1
  49. package/src/scripts/check_bridge_derivation.ts +22 -1
  50. package/src/scripts/check_bundle_path_leakage.ts +26 -0
  51. package/src/scripts/check_ci_strict_superset.ts +21 -3
  52. package/src/scripts/check_claims.ts +22 -0
  53. package/src/scripts/check_cli_registry_budget_sync.ts +33 -4
  54. package/src/scripts/check_cluster_patterns.ts +26 -4
  55. package/src/scripts/check_command_count_messaging.ts +20 -4
  56. package/src/scripts/check_comparison.ts +18 -0
  57. package/src/scripts/check_condensed_paths.ts +23 -2
  58. package/src/scripts/check_consumer_scope_flip.ts +21 -2
  59. package/src/scripts/check_council_config_location.ts +20 -0
  60. package/src/scripts/check_council_layout.ts +22 -2
  61. package/src/scripts/check_council_references.ts +23 -1
  62. package/src/scripts/check_dependency_floors.ts +19 -3
  63. package/src/scripts/check_discovery_determinism.ts +26 -0
  64. package/src/scripts/check_enforcement_coverage.ts +20 -0
  65. package/src/scripts/check_evaluator_budgets.ts +23 -0
  66. package/src/scripts/check_finding_dispositions.ts +29 -0
  67. package/src/scripts/check_gate_coverage.ts +63 -22
  68. package/src/scripts/check_gate_paths.ts +30 -5
  69. package/src/scripts/check_generated_artefact_headers.ts +21 -0
  70. package/src/scripts/check_generator_output_coverage.ts +23 -0
  71. package/src/scripts/check_gitignore_freshness.ts +23 -0
  72. package/src/scripts/check_host_loadability.ts +34 -8
  73. package/src/scripts/check_installer_import_purity.ts +22 -8
  74. package/src/scripts/check_kernel_prefix_stability.ts +19 -1
  75. package/src/scripts/check_kernel_rule_bundle.ts +23 -0
  76. package/src/scripts/check_knowledge_cards.ts +13 -0
  77. package/src/scripts/check_knowledge_pages.ts +23 -0
  78. package/src/scripts/check_knowledge_sharing.ts +27 -0
  79. package/src/scripts/check_md_language.ts +24 -0
  80. package/src/scripts/check_media_deps.ts +23 -0
  81. package/src/scripts/check_memory.ts +26 -1
  82. package/src/scripts/check_memory_contradiction.ts +16 -1
  83. package/src/scripts/check_memory_proposal.ts +15 -0
  84. package/src/scripts/check_memory_similarity.ts +16 -0
  85. package/src/scripts/check_module_management_neutral.ts +19 -11
  86. package/src/scripts/check_no_conflict_markers.ts +22 -1
  87. package/src/scripts/check_no_external_sources.ts +26 -1
  88. package/src/scripts/check_no_local_settings_committed.ts +37 -10
  89. package/src/scripts/check_no_new_legacy_path.ts +30 -1
  90. package/src/scripts/check_one_off_location.ts +43 -0
  91. package/src/scripts/check_originality_freshness.ts +25 -1
  92. package/src/scripts/check_overlay_cascade_subdirs.ts +21 -0
  93. package/src/scripts/check_pack_size.ts +22 -0
  94. package/src/scripts/check_preamble_payload_budget.ts +35 -5
  95. package/src/scripts/check_proposal.ts +24 -1
  96. package/src/scripts/check_public_catalog_links.ts +27 -0
  97. package/src/scripts/check_public_links.ts +19 -0
  98. package/src/scripts/check_quality_regression.ts +19 -0
  99. package/src/scripts/check_reach_channels.ts +44 -0
  100. package/src/scripts/check_reach_staleness.ts +27 -0
  101. package/src/scripts/check_release_adjacent_health.ts +28 -0
  102. package/src/scripts/check_release_highlights.ts +34 -0
  103. package/src/scripts/check_release_includes_discovery.ts +25 -6
  104. package/src/scripts/check_release_pr_shape.ts +18 -1
  105. package/src/scripts/check_release_published.ts +21 -0
  106. package/src/scripts/check_release_surface_equality.ts +22 -0
  107. package/src/scripts/check_release_trunk_sync.ts +19 -0
  108. package/src/scripts/check_reply_consistency.ts +22 -0
  109. package/src/scripts/check_roadmap_trackable.ts +20 -0
  110. package/src/scripts/check_role_doc_links.ts +23 -0
  111. package/src/scripts/check_rule_invariants.ts +14 -0
  112. package/src/scripts/check_secret_leak.ts +47 -0
  113. package/src/scripts/check_skill_gaps.ts +32 -0
  114. package/src/scripts/check_skill_requires.ts +19 -0
  115. package/src/scripts/check_source_pointer_freshness.ts +20 -0
  116. package/src/scripts/check_structural_breaking.ts +29 -2
  117. package/src/scripts/check_surface_tiers.ts +34 -0
  118. package/src/scripts/check_template_pin_drift.ts +23 -1
  119. package/src/scripts/check_test_coverage_diff.ts +17 -1
  120. package/src/scripts/check_token_optimizer_freshness.ts +18 -3
  121. package/src/scripts/check_token_quality_golden.ts +21 -0
  122. package/src/scripts/check_token_regression.ts +25 -0
  123. package/src/scripts/check_tracked_but_ignored.ts +26 -0
  124. package/src/scripts/check_trigger_eval_presence.ts +23 -1
  125. package/src/scripts/check_trigger_evals.ts +36 -0
  126. package/src/scripts/check_trunk_drift.ts +23 -0
  127. package/src/scripts/check_update_banner.ts +24 -0
  128. package/src/scripts/gate_scope_classify.ts +513 -0
  129. package/src/scripts/hook_manifest.yaml +1 -1
  130. package/src/scripts/hooks/block_kernel_rule_writes.ts +1 -1
  131. package/src/scripts/hooks/concern_registry.ts +2 -2
  132. package/src/scripts/lint_agent_security.ts +19 -0
  133. package/src/scripts/lint_agent_skill_names.ts +30 -0
  134. package/src/scripts/lint_agents_layout.ts +20 -0
  135. package/src/scripts/lint_agents_md.ts +20 -0
  136. package/src/scripts/lint_archived_skills.ts +24 -3
  137. package/src/scripts/lint_behavioural_eval_freshness.ts +29 -3
  138. package/src/scripts/lint_bench_ab.ts +25 -0
  139. package/src/scripts/lint_bench_corpus.ts +20 -4
  140. package/src/scripts/lint_breaking_changes_index.ts +24 -0
  141. package/src/scripts/lint_budget_ownership.ts +20 -3
  142. package/src/scripts/lint_changelog_rollback.ts +24 -0
  143. package/src/scripts/lint_command_flow_coverage.ts +22 -0
  144. package/src/scripts/lint_command_tiers.ts +29 -5
  145. package/src/scripts/lint_commit_subjects.ts +15 -0
  146. package/src/scripts/lint_confusables.ts +24 -0
  147. package/src/scripts/lint_consumer_internal_refs.ts +24 -0
  148. package/src/scripts/lint_design_quality.ts +22 -0
  149. package/src/scripts/lint_design_slop.ts +23 -0
  150. package/src/scripts/lint_discovery_manifest.ts +23 -0
  151. package/src/scripts/lint_discovery_vocabulary.ts +19 -0
  152. package/src/scripts/lint_documented_commands.ts +23 -0
  153. package/src/scripts/lint_empty_roadmaps.ts +23 -0
  154. package/src/scripts/lint_eval_fixture_citations.ts +19 -0
  155. package/src/scripts/lint_eval_freshness.ts +35 -0
  156. package/src/scripts/lint_examples.ts +18 -3
  157. package/src/scripts/lint_explain_trace.ts +23 -0
  158. package/src/scripts/lint_featured_skills.ts +26 -1
  159. package/src/scripts/lint_flows.ts +19 -0
  160. package/src/scripts/lint_frontmatter_boilerplate.ts +20 -0
  161. package/src/scripts/lint_ghostwriter_source.ts +26 -6
  162. package/src/scripts/lint_glama_drift.ts +24 -0
  163. package/src/scripts/lint_global_paths.ts +24 -0
  164. package/src/scripts/lint_governed_writes.ts +20 -0
  165. package/src/scripts/lint_hidden_unicode.ts +25 -0
  166. package/src/scripts/lint_hook_concern_budget.ts +39 -6
  167. package/src/scripts/lint_hook_manifest.ts +22 -0
  168. package/src/scripts/lint_instruction_smuggling.ts +23 -0
  169. package/src/scripts/lint_knowledge_scale.ts +32 -2
  170. package/src/scripts/lint_legal_pack.ts +31 -0
  171. package/src/scripts/lint_marketplace.ts +26 -0
  172. package/src/scripts/lint_marketplace_install_completeness.ts +23 -0
  173. package/src/scripts/lint_mcp_config_security.ts +24 -0
  174. package/src/scripts/lint_mcp_registry_manifest.ts +22 -1
  175. package/src/scripts/lint_memory_tripwire.ts +35 -0
  176. package/src/scripts/lint_missions.ts +23 -7
  177. package/src/scripts/lint_model_tier_coverage.ts +18 -0
  178. package/src/scripts/lint_namespace_collisions.ts +19 -0
  179. package/src/scripts/lint_no_new_atomic_commands.ts +33 -0
  180. package/src/scripts/lint_no_python_twin_rationale.ts +18 -0
  181. package/src/scripts/lint_one_off_age.ts +38 -2
  182. package/src/scripts/lint_orchestration_dsl.ts +23 -3
  183. package/src/scripts/lint_orchestrator_auto_detect.ts +19 -12
  184. package/src/scripts/lint_originality.ts +22 -4
  185. package/src/scripts/lint_originality_shingles.ts +28 -3
  186. package/src/scripts/lint_output_slop.ts +19 -0
  187. package/src/scripts/lint_override_kernel_guard.ts +30 -0
  188. package/src/scripts/lint_pack_first_win.ts +23 -0
  189. package/src/scripts/lint_pack_risk_class.ts +19 -3
  190. package/src/scripts/lint_persistence.ts +19 -0
  191. package/src/scripts/lint_persona_governance.ts +18 -3
  192. package/src/scripts/lint_positioning.ts +23 -0
  193. package/src/scripts/lint_pre_migration_refs.ts +24 -0
  194. package/src/scripts/lint_profile_overlay_set_only.ts +24 -0
  195. package/src/scripts/lint_provenance.ts +21 -0
  196. package/src/scripts/lint_provenance_vocabulary.ts +24 -1
  197. package/src/scripts/lint_readme_jargon.ts +21 -3
  198. package/src/scripts/lint_readme_serial_comma.ts +21 -3
  199. package/src/scripts/lint_readme_size.ts +21 -3
  200. package/src/scripts/lint_regression.ts +28 -0
  201. package/src/scripts/lint_roadmap_blockers.ts +43 -0
  202. package/src/scripts/lint_roadmap_ci_steps.ts +45 -0
  203. package/src/scripts/lint_roadmap_complexity.ts +47 -0
  204. package/src/scripts/lint_roadmap_later_disposition.ts +23 -0
  205. package/src/scripts/lint_rule_interactions.ts +16 -2
  206. package/src/scripts/lint_rule_tiers.ts +19 -6
  207. package/src/scripts/lint_showcase_sessions.ts +19 -3
  208. package/src/scripts/lint_skill_descriptions.ts +19 -0
  209. package/src/scripts/lint_skill_frontmatter_safety.ts +24 -0
  210. package/src/scripts/lint_skill_originality.ts +18 -3
  211. package/src/scripts/lint_skill_scripts_readonly.ts +20 -0
  212. package/src/scripts/lint_skill_tools.ts +84 -145
  213. package/src/scripts/lint_spawn_payload.ts +27 -1
  214. package/src/scripts/lint_store_boundary.ts +19 -0
  215. package/src/scripts/lint_subagent_determinism.ts +23 -1
  216. package/src/scripts/lint_supported_tools_matrix.ts +18 -0
  217. package/src/scripts/lint_surface_matrix.ts +19 -3
  218. package/src/scripts/lint_ticket_buildable.ts +23 -0
  219. package/src/scripts/lint_topics_yaml.ts +19 -12
  220. package/src/scripts/lint_trigger_precision.ts +27 -0
  221. package/src/scripts/lint_trust_coherence.ts +21 -0
  222. package/src/scripts/lint_ui_stack_bundles.ts +17 -3
  223. package/src/scripts/lint_value_dashboard.ts +19 -3
  224. package/src/scripts/lint_versioned_cache.ts +19 -1
  225. package/src/scripts/lint_workflow_security.ts +22 -14
  226. package/src/scripts/pr_url_reminder_hook.ts +1 -1
  227. package/src/scripts/skill_collision_clusters.ts +37 -3
  228. package/src/scripts/skill_discovery.ts +20 -0
  229. package/src/scripts/skill_eval_coverage.ts +22 -0
  230. package/src/scripts/skill_overlap.ts +32 -6
  231. package/src/scripts/skill_preview.ts +31 -0
  232. package/src/scripts/skill_trigger_eval.ts +18 -0
  233. package/src/scripts/skill_usage_collect.ts +21 -0
  234. package/src/scripts/skill_usage_report.ts +21 -0
  235. package/src/scripts/sweep_dead_scan_roots.ts +11 -3
  236. package/src/scripts/verify_physical_move.ts +24 -0
  237. package/src/scripts/worktree_cleanup_check.ts +319 -4
  238. package/src/scripts/check_bite_sized_granularity.ts +0 -116
  239. package/src/scripts/lint_workspace_boundary.ts +0 -267
@@ -53,6 +53,18 @@ import { parse as parseYaml } from 'yaml';
53
53
 
54
54
  import * as v1 from './bench_ab_task_runner.js';
55
55
  import * as scoring from './_lib/bench_ab_scoring_v2.js';
56
+ import {
57
+ SweepBudget,
58
+ activation_verdict,
59
+ audit_activation,
60
+ expected_injection,
61
+ is_bare_alias,
62
+ tier_for_model,
63
+ verify_model_id,
64
+ type TierRates,
65
+ type TokensBreakdown,
66
+ } from './_lib/bench_ab_activation.js';
67
+ import { load_pricing } from './_lib/bench_cost.js';
56
68
 
57
69
  const _HERE = fileURLToPath(import.meta.url);
58
70
 
@@ -66,6 +78,7 @@ const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-tra
66
78
  const FIXTURES_ROOT = path.join(REPO_ROOT, 'internal', 'bench', 'ab');
67
79
  const REPORTS_DIR = path.join(REPO_ROOT, 'internal', 'bench', 'reports', 'ab-v2');
68
80
  const CHECKPOINTS_DIR = path.join(REPORTS_DIR, 'checkpoints');
81
+ const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
69
82
  // CRITICAL (2026-06-15): clones MUST live OUTSIDE the agent-config repo. A clone
70
83
  // under the repo lets Claude discover the repo's own project surface (CLAUDE.md /
71
84
  // AGENTS.md / .claude/rules+skills) walking up from the cwd — so the `vanilla`
@@ -461,6 +474,31 @@ export function run_live_codex(
461
474
  };
462
475
  }
463
476
 
477
+ /**
478
+ * Delta #1–#3 — stamp the measurement-integrity block onto a trial record.
479
+ *
480
+ * `tokens_breakdown` is preserved here (delta #2) because it is the ONLY
481
+ * observable that can see a plugin-arm collapse: `injected_chars` is the length
482
+ * of a file the harness wrote itself, and it is 0 for every arm that arrives
483
+ * through global settings. Discarding the breakdown made the audit
484
+ * unreconstructable even post-hoc.
485
+ */
486
+ export function integrity_fields(run: Dict, spec: ArmSpec, injected_chars: number, model: string | null): Dict {
487
+ const breakdown = (run['tokens_breakdown'] ?? {}) as TokensBreakdown | Record<string, never>;
488
+ const models_seen = Array.isArray(run['models_seen']) ? (run['models_seen'] as string[]) : [];
489
+ return {
490
+ tokens_breakdown: breakdown,
491
+ models_seen,
492
+ activation: activation_verdict({
493
+ expected: expected_injection(spec),
494
+ tokens_breakdown: breakdown,
495
+ injected_chars,
496
+ errored: _pyBool(run['errored']),
497
+ }),
498
+ model_check: model ? verify_model_id(model, models_seen) : { ok: true, reason: 'no model pinned' },
499
+ };
500
+ }
501
+
464
502
  export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
465
503
  const spec = ARMS[arm] as ArmSpec;
466
504
  if (spec.recursive) {
@@ -501,6 +539,7 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
501
539
  discipline_pass: score.discipline_pass,
502
540
  metrics: trajectory_metrics(run, score),
503
541
  injected_chars: sp_text ? sp_text.length : 0,
542
+ ...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
504
543
  };
505
544
  }
506
545
 
@@ -618,6 +657,10 @@ export function run_one_recursive(
618
657
  injected_chars: last_verdict_len,
619
658
  depth_reached: depth,
620
659
  stop_reason,
660
+ // `injected_chars` here counts the re-attempt VERDICT text, not an arm
661
+ // injection — the recursive arm's channel is the plugin. Pass 0 so the
662
+ // audit reads the arm's real channel instead of flagging the verdict.
663
+ ...integrity_fields(run, ARMS['package-recursive'] as ArmSpec, 0, opts.model),
621
664
  };
622
665
  }
623
666
 
@@ -767,17 +810,28 @@ export function collect_records(
767
810
  run_fn: (task: Dict, arm: string, seed: number) => Dict,
768
811
  ckpt: CheckpointIO | null = null,
769
812
  log: (msg: string) => void = (m) => process.stderr.write(m),
770
- ): { records: Dict[]; executed: number; reused: number } {
813
+ /**
814
+ * Delta #4 — sweep-level spend guard. Called after each executed run with
815
+ * that run's record; a non-null return aborts the sweep. Records collected
816
+ * so far are still returned (and are already on the checkpoint), so an
817
+ * abort costs no completed work.
818
+ */
819
+ guard: ((record: Dict) => string | null) | null = null,
820
+ ): { records: Dict[]; executed: number; reused: number; aborted: string | null } {
771
821
  const total = tasks.length * arms.length * seeds;
772
822
  let done = 0;
773
823
  let executed = 0;
774
824
  let reused = 0;
825
+ let aborted: string | null = null;
775
826
  const records: Dict[] = [];
776
827
  for (const task of tasks) {
777
828
  const per_arm: Record<string, Dict[]> = {};
778
829
  for (const arm of arms) {
779
830
  const seed_runs: Dict[] = [];
780
831
  for (let seed = 0; seed < seeds; seed += 1) {
832
+ if (aborted) {
833
+ break;
834
+ }
781
835
  done += 1;
782
836
  const key = run_key(String(task['id']), arm, seed);
783
837
  const cached = ckpt?.completed.get(key);
@@ -794,6 +848,7 @@ export function collect_records(
794
848
  append_checkpoint(ckpt.path, key, r);
795
849
  }
796
850
  seed_runs.push(r);
851
+ aborted = guard?.(r) ?? null;
797
852
  }
798
853
  per_arm[arm] = seed_runs;
799
854
  }
@@ -803,8 +858,11 @@ export function collect_records(
803
858
  rule: task['rule'],
804
859
  arms: per_arm,
805
860
  });
861
+ if (aborted) {
862
+ break;
863
+ }
806
864
  }
807
- return { records, executed, reused };
865
+ return { records, executed, reused, aborted };
808
866
  }
809
867
 
810
868
  export function main(argv: string[] | null = null): number {
@@ -841,11 +899,44 @@ export function main(argv: string[] | null = null): number {
841
899
  }
842
900
  }
843
901
 
902
+ // Delta #3 — refuse a bare alias BEFORE any spend. An alias resolves
903
+ // server-side to whatever is current, so a report produced under one is
904
+ // unreproducible: nothing in it records which model actually answered.
905
+ if (is_bare_alias(args.model)) {
906
+ process.stderr.write(
907
+ `bench_ab_v2: refusing bare model alias "${args.model}" — pin a full id ` +
908
+ `(e.g. claude-sonnet-4-6). An alias makes the report unreproducible.\n`,
909
+ );
910
+ return 2;
911
+ }
912
+
913
+ // Delta #4 — sweep-level cap. `--max-budget-usd` is PER RUN, so 30×4×3 at
914
+ // --budget 3.5 has a $1,260 ceiling with nothing to stop it. Prices come
915
+ // from internal/bench/pricing.yaml; an unrecognised model tier leaves the
916
+ // guard inert rather than silently mispricing the sweep.
917
+ //
918
+ // Resolved HERE, with the other argument validation, rather than next to
919
+ // its use below: a cap the harness cannot price is a bad argument, and a
920
+ // bad argument must be reported whether or not the host CLI is installed.
921
+ // Behind the CLI-presence check it was unreachable on any machine without
922
+ // `claude` — including CI.
923
+ const [rates] = load_pricing(PRICING_PATH);
924
+ const tier = tier_for_model(args.model);
925
+ const tier_rates: TierRates | null = tier && rates[tier] ? (rates[tier] as TierRates) : null;
926
+ if (args.max_usd !== null && tier_rates === null) {
927
+ process.stderr.write(
928
+ `bench_ab_v2: --max-usd given but no pricing row matches model "${args.model}" ` +
929
+ `(${_relToRootPosix(PRICING_PATH)}) — the sweep cap cannot be enforced.\n`,
930
+ );
931
+ return 2;
932
+ }
933
+
844
934
  if (args.mode === 'dry-run') {
845
935
  process.stdout.write(
846
936
  `bench_ab_v2: DRY — ${tasks.length} tasks × ${arms.length} arms × ` +
847
937
  `${args.seeds} seeds = ${tasks.length * arms.length * args.seeds} runs ` +
848
- `(model=${args.model}, budget=${_pyNumStr(args.budget)}). No spend.\n`,
938
+ `(model=${args.model}, budget=${_pyNumStr(args.budget)}, ` +
939
+ `sweep cap=${args.max_usd === null ? 'none' : `$${_pyNumStr(args.max_usd)}`}). No spend.\n`,
849
940
  );
850
941
  return 0;
851
942
  }
@@ -891,7 +982,9 @@ export function main(argv: string[] | null = null): number {
891
982
  ckpt = { path: ckpt_path, completed };
892
983
  }
893
984
 
894
- const { records } = collect_records(
985
+ const budget = new SweepBudget(args.max_usd, tier_rates);
986
+
987
+ const { records, aborted } = collect_records(
895
988
  tasks,
896
989
  arms,
897
990
  args.seeds,
@@ -905,8 +998,17 @@ export function main(argv: string[] | null = null): number {
905
998
  host: args.host,
906
999
  }),
907
1000
  ckpt,
1001
+ undefined,
1002
+ (r) => budget.add(r['tokens_breakdown'] as TokensBreakdown | undefined),
908
1003
  );
909
1004
 
1005
+ // Delta #1 — the activation audit. Runs BEFORE the report is judged usable:
1006
+ // the paired plugin direction needs the whole record set, so it cannot be a
1007
+ // per-trial check alone. `vanilla` is the baseline; every arm that is
1008
+ // 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 });
1011
+
910
1012
  const stamp = v1.utc_stamp();
911
1013
  const payload: Dict = {
912
1014
  schema: 'ab-bench-v2/0.1',
@@ -915,9 +1017,17 @@ export function main(argv: string[] | null = null): number {
915
1017
  seeds: args.seeds,
916
1018
  arms,
917
1019
  budget_usd_per_run: args.budget,
1020
+ sweep_cap_usd: args.max_usd,
1021
+ sweep_spend_usd: new PyFloat(Number(budget.spent_usd.toFixed(4))),
918
1022
  placebo_chars,
919
1023
  corpus: 'ab-trackb-v2',
920
1024
  host: args.host,
1025
+ activation_audit: {
1026
+ checked: audit.checked,
1027
+ skipped: audit.skipped,
1028
+ violations: audit.violations as unknown as Json,
1029
+ },
1030
+ aborted: aborted ?? null,
921
1031
  records,
922
1032
  };
923
1033
  const out = path.join(REPORTS_DIR, `${stamp}-ab-v2-paired.json`);
@@ -928,8 +1038,31 @@ export function main(argv: string[] | null = null): number {
928
1038
  fs.rmSync(ckpt.path);
929
1039
  }
930
1040
  process.stdout.write(
931
- `bench_ab_v2: wrote ${_relToRootPosix(out)} ` + `(${records.length} tasks, ${total} runs)\n`,
1041
+ `bench_ab_v2: wrote ${_relToRootPosix(out)} ` +
1042
+ `(${records.length} tasks, ${total} runs, spend $${budget.spent_usd.toFixed(2)})\n`,
932
1043
  );
1044
+
1045
+ // The report is always written — an aborted or integrity-failed sweep still
1046
+ // cost money and its raw runs stay inspectable. The EXIT CODE is what makes
1047
+ // the failure non-ignorable, so a caller cannot mistake an invalid sweep for
1048
+ // a publishable one.
1049
+ if (aborted) {
1050
+ process.stderr.write(`bench_ab_v2: ${aborted}\n`);
1051
+ return 2;
1052
+ }
1053
+ if (audit.violations.length > 0) {
1054
+ process.stderr.write(
1055
+ `bench_ab_v2: ACTIVATION AUDIT FAILED — ${audit.violations.length} violation(s) ` +
1056
+ `over ${audit.checked} checked pair(s). This report is NOT publishable.\n`,
1057
+ );
1058
+ for (const v of audit.violations.slice(0, 20)) {
1059
+ process.stderr.write(` ${v.kind}: ${v.task_id} · ${v.arm} · seed ${v.seed} — ${v.detail}\n`);
1060
+ }
1061
+ if (audit.violations.length > 20) {
1062
+ process.stderr.write(` … ${audit.violations.length - 20} more (see the report)\n`);
1063
+ }
1064
+ return 2;
1065
+ }
933
1066
  return 0;
934
1067
  }
935
1068
 
@@ -942,6 +1075,8 @@ interface ParsedArgs {
942
1075
  limit: number;
943
1076
  model: string;
944
1077
  budget: number;
1078
+ /** Sweep-wide USD cap (delta #4); `null` = uncapped, the pre-existing behaviour. */
1079
+ max_usd: number | null;
945
1080
  timeout: number;
946
1081
  mode: string;
947
1082
  host: string;
@@ -960,13 +1095,14 @@ function parse_args(argv: string[]): ParsedArgs {
960
1095
  limit: 0,
961
1096
  model: 'claude-sonnet-4-6',
962
1097
  budget: 1.0,
1098
+ max_usd: null,
963
1099
  timeout: 180,
964
1100
  mode: 'live',
965
1101
  host: 'claude',
966
1102
  checkpoint: true,
967
1103
  fresh: false,
968
1104
  };
969
- const usage = `usage: ${prog} [-h] [--arms ARMS] [--seeds SEEDS] [--tasks TASKS] [--limit LIMIT] [--model MODEL] [--budget BUDGET] [--timeout TIMEOUT] [--mode {live,dry-run}] [--host {claude,codex}] [--no-checkpoint] [--fresh]\n`;
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`;
970
1106
  const argErr = (msg: string): never => {
971
1107
  process.stderr.write(usage);
972
1108
  process.stderr.write(`${prog}: error: ${msg}\n`);
@@ -1014,6 +1150,11 @@ function parse_args(argv: string[]): ParsedArgs {
1014
1150
  i += 1;
1015
1151
  } else if (a.startsWith('--budget=')) {
1016
1152
  out.budget = _pyFloatArg(a.slice('--budget='.length), '--budget');
1153
+ } else if (a === '--max-usd') {
1154
+ out.max_usd = _pyFloatArg(need(i, '--max-usd'), '--max-usd');
1155
+ i += 1;
1156
+ } else if (a.startsWith('--max-usd=')) {
1157
+ out.max_usd = _pyFloatArg(a.slice('--max-usd='.length), '--max-usd');
1017
1158
  } else if (a === '--timeout') {
1018
1159
  out.timeout = _pyInt(need(i, '--timeout'), '--timeout');
1019
1160
  i += 1;
@@ -291,8 +291,37 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
291
291
  let dis_t_sum = 0.0;
292
292
  let dis_b_sum = 0.0;
293
293
  let disn = 0.0;
294
+ // Delta #5 — attrition. A dropped pair is not missing-at-random: a budget
295
+ // cap or a timeout fires preferentially on the arm doing MORE work, so
296
+ // silently excluding those pairs biases the surviving sample toward the
297
+ // baseline. Report which side died and in which status bucket, so a reader
298
+ // can see whether the drops are balanced before trusting the estimate.
299
+ let pairs_seen = 0;
300
+ let dropped_treatment_only = 0;
301
+ let dropped_baseline_only = 0;
302
+ let dropped_both = 0;
303
+ const dropped_buckets: Record<string, number> = {};
294
304
  for (const { rt, rb } of _pairs(records, arm_t, arm_b)) {
295
- if (!_pyTruthy(rt['errored']) && !_pyTruthy(rb['errored'])) {
305
+ pairs_seen += 1;
306
+ const t_err = _pyTruthy(rt['errored']);
307
+ const b_err = _pyTruthy(rb['errored']);
308
+ if (t_err || b_err) {
309
+ if (t_err && b_err) {
310
+ dropped_both += 1;
311
+ } else if (t_err) {
312
+ dropped_treatment_only += 1;
313
+ } else {
314
+ dropped_baseline_only += 1;
315
+ }
316
+ for (const r of [t_err ? rt : null, b_err ? rb : null]) {
317
+ if (r === null) {
318
+ continue;
319
+ }
320
+ const bucket = _strOr(_dictOr(r['metrics'])['status_bucket'], 'unknown');
321
+ dropped_buckets[bucket] = (dropped_buckets[bucket] ?? 0) + 1;
322
+ }
323
+ }
324
+ if (!t_err && !b_err) {
296
325
  const t = _pyTruthy(rt['capability_pass']);
297
326
  const bb = _pyTruthy(rb['capability_pass']);
298
327
  capn += 1;
@@ -341,6 +370,18 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
341
370
  rank_biserial: PF(wil.rank_biserial),
342
371
  n_nonzero: wil.n,
343
372
  },
373
+ attrition: {
374
+ pairs_seen,
375
+ pairs_analysed: capn,
376
+ pairs_dropped: pairs_seen - capn,
377
+ dropped_treatment_only,
378
+ dropped_baseline_only,
379
+ dropped_both,
380
+ // Positive = the treatment arm died more often than the baseline,
381
+ // i.e. the surviving sample is biased toward the baseline.
382
+ drop_asymmetry: dropped_treatment_only - dropped_baseline_only,
383
+ dropped_by_status_bucket: dropped_buckets,
384
+ },
344
385
  };
345
386
  }
346
387
 
@@ -509,7 +550,9 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
509
550
  );
510
551
  L.push(
511
552
  '> 4. **Paired design**, errored runs excluded; McNemar (capability) + ' +
512
- 'Wilcoxon signed-rank (discipline) + effect sizes.',
553
+ 'Wilcoxon signed-rank (discipline) + effect sizes. Exclusion is NOT ' +
554
+ 'assumed harmless — see each comparison’s Table 4 for how many pairs ' +
555
+ 'were dropped and whether the drops favour one arm.',
513
556
  );
514
557
  L.push(
515
558
  '> 5. **Not comparable to SWE-bench / GAIA / Fable scores** — a different ' +
@@ -597,6 +640,29 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
597
640
  L.push('|---|---|---|---|');
598
641
  L.push(`| mean tokens | ${_thousands(tb)} | ${_thousands(tt)} | ${_thousandsSigned(tt - tb)} |`);
599
642
  L.push('');
643
+ const at = _dictOr(cmp['attrition']);
644
+ L.push('### Table 4 — attrition (dropped pairs are not missing-at-random)');
645
+ L.push('');
646
+ L.push('| pairs seen | analysed | dropped | treatment-only | baseline-only | both | asymmetry |');
647
+ L.push('|---|---|---|---|---|---|---|');
648
+ L.push(
649
+ `| ${_pyStr(at['pairs_seen'])} | ${_pyStr(at['pairs_analysed'])} | ${_pyStr(at['pairs_dropped'])} ` +
650
+ `| ${_pyStr(at['dropped_treatment_only'])} | ${_pyStr(at['dropped_baseline_only'])} ` +
651
+ `| ${_pyStr(at['dropped_both'])} | ${_fmtSignedInt(at['drop_asymmetry'])} |`,
652
+ );
653
+ const dropBuckets = Object.entries(_dictOr(at['dropped_by_status_bucket']))
654
+ .map(([k, v]) => `${k}:${_pyStr(v)}`)
655
+ .join(', ');
656
+ L.push('');
657
+ L.push(`Dropped runs by status bucket: ${dropBuckets || '_none_'}.`);
658
+ L.push('');
659
+ L.push(
660
+ '> A positive asymmetry means the treatment arm errored out more often than ' +
661
+ 'the baseline. Budget caps and timeouts fire preferentially on the arm doing ' +
662
+ 'more work, so the surviving sample is then biased TOWARD the baseline and the ' +
663
+ 'measured lift is a floor, not an estimate.',
664
+ );
665
+ L.push('');
600
666
  }
601
667
  L.push('## Status buckets (trajectory)');
602
668
  L.push('');
@@ -655,6 +721,12 @@ function _fmtSigned3(v: unknown): string {
655
721
  return body.startsWith('-') ? body : `+${body}`;
656
722
  }
657
723
 
724
+ /** `f"{n:+d}"` over an int-ish JSON value (attrition asymmetry). */
725
+ function _fmtSignedInt(v: unknown): string {
726
+ const n = _pyIntTrunc(_pyFloat(v));
727
+ return n < 0 ? String(n) : `+${n}`;
728
+ }
729
+
658
730
  /** `f"{n:,}"` over `int(value)`. */
659
731
  function _thousands(n: number): string {
660
732
  return _pyThousands(_pyIntTrunc(n));
@@ -35,6 +35,8 @@ import * as fs from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
  import { fileURLToPath, pathToFileURL } from 'node:url';
37
37
 
38
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
39
+
38
40
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
39
41
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
40
42
  const ADR_DIR = path.join(REPO_ROOT, 'docs', 'decisions');
@@ -149,6 +151,27 @@ export function check(dir: string = ADR_DIR): AdrFinding[] {
149
151
  function main(argv: string[]): number {
150
152
  const as_json = argv.includes('--json');
151
153
  const findings = check();
154
+ const total = fs.existsSync(ADR_DIR)
155
+ ? fs.readdirSync(ADR_DIR).filter((f) => /^ADR-.*\.md$/.test(f)).length
156
+ : 0;
157
+
158
+ // `check()` returns findings, never a count, so a moved `docs/decisions/`
159
+ // yields an empty list — reported as "0 ADR(s) · 0 error(s)" and green.
160
+ // Exit 1 is the violation code; 2 stays reserved for usage/env errors.
161
+ try {
162
+ assertScanned({
163
+ gate: 'check_adr_frontmatter',
164
+ scanned: total,
165
+ units: 'ADR file(s)',
166
+ roots: ['docs/decisions'],
167
+ });
168
+ } catch (exc) {
169
+ if (exc instanceof DeadScopeError) {
170
+ process.stderr.write(`❌ ${exc.message}\n`);
171
+ return 1;
172
+ }
173
+ throw exc;
174
+ }
152
175
 
153
176
  if (as_json) {
154
177
  process.stdout.write(JSON.stringify({ findings }, null, 2) + '\n');
@@ -157,9 +180,6 @@ function main(argv: string[]): number {
157
180
 
158
181
  const errors = findings.filter((f) => f.level === 'error');
159
182
  const warns = findings.filter((f) => f.level === 'warn');
160
- const total = fs.existsSync(ADR_DIR)
161
- ? fs.readdirSync(ADR_DIR).filter((f) => /^ADR-.*\.md$/.test(f)).length
162
- : 0;
163
183
 
164
184
  process.stdout.write(
165
185
  `ADR frontmatter: ${total} ADR(s) · ${errors.length} error(s) · ` +
@@ -29,6 +29,8 @@ import process from 'node:process';
29
29
  import { fileURLToPath, pathToFileURL } from 'node:url';
30
30
  import YAML from 'yaml';
31
31
 
32
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
33
+
32
34
  // src/scripts/check_always_budget.ts → parents[2] is the repo root
33
35
  // (mirrors the Python module's Path(__file__).resolve().parents[2]).
34
36
  const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -552,6 +554,28 @@ function main(): number {
552
554
  return 3;
553
555
  }
554
556
 
557
+ // The scanned unit is every projected rule, not the `type: always` subset the
558
+ // budget is measured over: `isDir` above catches a MISSING projection, but a
559
+ // present-and-empty one (a sync that produced no rules) reaches here, and
560
+ // "no always-rules" cannot distinguish that from a tree whose rules are all
561
+ // `type: auto`. Exit 3 stays — this file's documented internal-error code,
562
+ // already used for the empty case, and the Taskfile fails on any non-zero, so
563
+ // this was never a silent green.
564
+ try {
565
+ assertScanned({
566
+ gate: 'check_always_budget',
567
+ scanned: allRules().length,
568
+ units: 'projected rule file(s)',
569
+ roots: [path.relative(REPO_ROOT, RULES_DIR)],
570
+ });
571
+ } catch (exc) {
572
+ if (exc instanceof DeadScopeError) {
573
+ process.stderr.write(`❌ ${exc.message}\n`);
574
+ return 3;
575
+ }
576
+ throw exc;
577
+ }
578
+
555
579
  const rules = allwaysRules();
556
580
  if (rules.length === 0) {
557
581
  process.stderr.write(`❌ no always-rules found under ${RULES_DIR}\n`);
@@ -33,6 +33,7 @@ import * as fs from 'node:fs';
33
33
  import * as path from 'node:path';
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
35
 
36
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
36
37
  import {
37
38
  apply_schema_defaults,
38
39
  load_schema,
@@ -153,6 +154,16 @@ function _check(manifestPath: string): [number, string[]] {
153
154
  const artefacts = Array.isArray(manifest['artefacts'])
154
155
  ? (manifest['artefacts'] as Json[])
155
156
  : [];
157
+ // A readable manifest with an empty (or non-array) `artefacts` reports
158
+ // "0 artefact checksums verified" and exits green — the drift this gate
159
+ // exists to catch would be invisible in exactly the case where the
160
+ // generator produced nothing.
161
+ assertScanned({
162
+ gate: 'check_artefact_checksums',
163
+ scanned: artefacts.length,
164
+ units: 'manifest artefact(s)',
165
+ roots: [manifestPath],
166
+ });
156
167
  for (const artRaw of artefacts) {
157
168
  const art = (artRaw ?? {}) as JsonObject;
158
169
  const rel = art['path'];
@@ -238,7 +249,19 @@ function parse_args(argv: readonly string[]): ParsedArgs {
238
249
  function main(argv: readonly string[]): number {
239
250
  const args = parse_args(argv);
240
251
 
241
- const [code, errors] = _check(args.manifest);
252
+ let code: number;
253
+ let errors: string[];
254
+ try {
255
+ [code, errors] = _check(args.manifest);
256
+ } catch (exc) {
257
+ if (exc instanceof DeadScopeError) {
258
+ // 1 is this gate's only non-zero code, and it already covers the
259
+ // sibling could-not-run case (`manifest not found`).
260
+ process.stderr.write(`error: ${exc.message}\n`);
261
+ return 1;
262
+ }
263
+ throw exc;
264
+ }
242
265
  if (code !== 0) {
243
266
  for (const e of errors.slice(0, 20)) {
244
267
  process.stderr.write(`error: ${e}\n`);
@@ -32,6 +32,7 @@ import * as fs from 'node:fs';
32
32
  import * as path from 'node:path';
33
33
  import { fileURLToPath, pathToFileURL } from 'node:url';
34
34
 
35
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
35
36
  import { canonical_counts } from './check_command_count_messaging.js';
36
37
  import { count, TARGETS } from './update_counts.js';
37
38
 
@@ -253,6 +254,25 @@ export function anchor_coverage_gaps(root: string = ROOT): CoverageGap[] {
253
254
 
254
255
  export function main(argv: readonly string[] = process.argv.slice(2)): number {
255
256
  const QUIET = argv.includes('--quiet');
257
+ // This gate walks no tree — it guards a hand-maintained surface list, and
258
+ // every missing entry is skipped with a warning so nothing hard-breaks on a
259
+ // doc rename. That tolerance is also the failure mode: a repo-wide docs
260
+ // move leaves every surface "skipped" and the gate reporting all counts in
261
+ // sync. Deleting these paths must fail, never quieten, the gate.
262
+ try {
263
+ assertWatchlistResolves({
264
+ gate: 'check_artefact_count_messaging',
265
+ candidates: [...SURFACES, ...STRUCTURED_SURFACES.map((s) => s.file)],
266
+ repoRoot: ROOT,
267
+ });
268
+ } catch (exc) {
269
+ if (exc instanceof DeadScopeError) {
270
+ // 1 is this gate's only non-zero code.
271
+ process.stderr.write(`❌ ${exc.message}\n`);
272
+ return 1;
273
+ }
274
+ throw exc;
275
+ }
256
276
  const expected: Record<string, number> = {};
257
277
  for (const [kind] of KIND_PATTERNS) {
258
278
  expected[kind] = canonical_for(kind);
@@ -24,6 +24,8 @@ import * as fs from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
26
 
27
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
28
+
27
29
  const QUIET = process.argv.slice(2).includes('--quiet');
28
30
 
29
31
  const STALE_DAYS = 90;
@@ -33,15 +35,6 @@ const MIN_USEFUL_LINES = 5;
33
35
  const _HERE = fileURLToPath(import.meta.url);
34
36
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
35
37
 
36
- function _exists(p: string): boolean {
37
- try {
38
- fs.statSync(p);
39
- return true;
40
- } catch {
41
- return false;
42
- }
43
- }
44
-
45
38
  function _emit(notes: readonly string[]): void {
46
39
  if (notes.length === 0) {
47
40
  if (!QUIET) {
@@ -60,12 +53,26 @@ function check(): number {
60
53
  const target = path.join(REPO_ROOT, '.augmentignore');
61
54
  const notes: string[] = [];
62
55
 
63
- if (!_exists(target)) {
64
- notes.push(
65
- '⚠️ .augmentignore is missing — run `/optimize augmentignore` to scaffold it.',
66
- );
67
- _emit(notes);
68
- return 0;
56
+ // The whole scan scope is this one file, so its absence IS the dead-scope
57
+ // case. It used to surface as a scaffolding hint on stdout, which reads the
58
+ // same whether the file was never written or the root moved. The exit code
59
+ // stays 0 because "always exits 0" is this file's pinned contract (advisory,
60
+ // not a gate) — there is no failure code to choose between.
61
+ try {
62
+ assertWatchlistResolves({
63
+ gate: 'check_augmentignore',
64
+ candidates: ['.augmentignore'],
65
+ repoRoot: REPO_ROOT,
66
+ });
67
+ } catch (exc) {
68
+ if (exc instanceof DeadScopeError) {
69
+ process.stderr.write(
70
+ `⚠️ ${exc.message}\n` +
71
+ ' Run `/optimize augmentignore` to scaffold it.\n',
72
+ );
73
+ return 0;
74
+ }
75
+ throw exc;
69
76
  }
70
77
 
71
78
  const mtimeMs = fs.statSync(target).mtimeMs;
@@ -45,6 +45,8 @@ import * as fs from 'node:fs';
45
45
  import * as path from 'node:path';
46
46
  import { fileURLToPath, pathToFileURL } from 'node:url';
47
47
 
48
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
49
+
48
50
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
49
51
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
50
52
  const BASELINE = path.join(REPO_ROOT, 'internal', 'reports', 'rule-backstop-debt.json');
@@ -107,6 +109,26 @@ function main(argv: string[]): number {
107
109
  const as_json = argv.includes('--json');
108
110
  const write = argv.includes('--write-baseline');
109
111
 
112
+ // This file owns no corpus — it ratchets five NAMED gates, so its scope is
113
+ // that list. A renamed or deleted gate script still spawns, fails, and lands
114
+ // in `unparsed`, whose message sends the reader to fix a regex that is not
115
+ // the problem. Asserted before `--write-baseline` too: a baseline anchored
116
+ // over phantom gates is a ratchet that can never rise. Exit 2 (usage / env
117
+ // error) rather than 1 — the ratchet could not run, no count rose.
118
+ try {
119
+ assertWatchlistResolves({
120
+ gate: 'check_backstop_debt',
121
+ candidates: GATES.map((g) => `src/scripts/${g.script}.ts`),
122
+ repoRoot: REPO_ROOT,
123
+ });
124
+ } catch (exc) {
125
+ if (exc instanceof DeadScopeError) {
126
+ process.stderr.write(`❌ ${exc.message}\n`);
127
+ return 2;
128
+ }
129
+ throw exc;
130
+ }
131
+
110
132
  const measured = GATES.map((g) => measure(g));
111
133
 
112
134
  if (as_json) {