@event4u/agent-config 9.21.0 → 9.23.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +69 -221
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/optimize/deep.md +13 -0
  5. package/dist/agent-src/commands/worktree/cleanup.md +17 -0
  6. package/dist/agent-src/commands/worktree/status.md +6 -0
  7. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  8. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  9. package/dist/agent-src/rules/context-hygiene.md +17 -0
  10. package/dist/agent-src/rules/downstream-changes.md +26 -0
  11. package/dist/agent-src/rules/think-before-action.md +25 -0
  12. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  13. package/dist/agent-src/rules/token-efficiency.md +13 -0
  14. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  15. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  16. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  17. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  18. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  19. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  20. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  21. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  22. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  23. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli/registry.js +1 -0
  26. package/dist/cli/registry.js.map +1 -1
  27. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  28. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  29. package/dist/cli-delegate/cmd_export.js +2 -1
  30. package/dist/cli-delegate/cmd_prune.js +2 -1
  31. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  32. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  33. package/dist/cli-delegate/cmd_sync.js +2 -1
  34. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  35. package/dist/cli-delegate/cmd_validate.js +2 -1
  36. package/dist/discovery/deprecation-report.md +1 -1
  37. package/dist/discovery/discovery-manifest.json +20 -20
  38. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  39. package/dist/discovery/discovery-manifest.summary.md +1 -1
  40. package/dist/discovery/orphan-report.md +1 -1
  41. package/dist/discovery/packs.json +2 -2
  42. package/dist/discovery/trust-report.md +1 -1
  43. package/dist/discovery/workspaces.json +2 -2
  44. package/dist/hooks/dispatch.js +79 -1
  45. package/dist/mcp/registry-manifest.json +2 -2
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/scripts/condense.js +47 -2
  48. package/dist/scripts/condense.js.map +1 -1
  49. package/dist/server/routes/settings.js +82 -0
  50. package/dist/server/routes/settings.js.map +1 -1
  51. package/dist/shared/settingsClasses.js +191 -0
  52. package/dist/shared/settingsClasses.js.map +1 -0
  53. package/docs/contracts/settings-classes.md +342 -0
  54. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  55. package/docs/guidelines/agent-infra/false-green.md +187 -0
  56. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  57. package/package.json +3 -1
  58. package/src/config/ci-local-parity.yml +12 -0
  59. package/src/config/evaluator-budgets.json +4 -4
  60. package/src/config/gate-coverage.yml +54 -12
  61. package/src/config/gate-violation-baselines.json +6 -0
  62. package/src/config/pack-size-budget.json +4 -3
  63. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  64. package/src/scripts/_dispatch.bash +16 -0
  65. package/src/scripts/_lib/bench_ab_activation.ts +368 -0
  66. package/src/scripts/_lib/gate_ledger.ts +295 -0
  67. package/src/scripts/_lib/gate_result.ts +101 -0
  68. package/src/scripts/_lib/gate_self_test.ts +129 -0
  69. package/src/scripts/_lib/measured_render.ts +97 -0
  70. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  71. package/src/scripts/bench_ab_task_runner.ts +18 -0
  72. package/src/scripts/bench_ab_v2_run.ts +147 -6
  73. package/src/scripts/bench_ab_v2_stats.ts +74 -2
  74. package/src/scripts/check_gate_completeness.ts +244 -0
  75. package/src/scripts/check_gate_coverage.ts +35 -6
  76. package/src/scripts/check_references.ts +125 -15
  77. package/src/scripts/check_review_dispositions.ts +14 -1
  78. package/src/scripts/check_suppression_hygiene.ts +503 -0
  79. package/src/scripts/condense.ts +47 -2
  80. package/src/scripts/hook_manifest.yaml +23 -0
  81. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  82. package/src/scripts/hooks/host_semantics.ts +140 -0
  83. package/src/scripts/lint_framework_leakage.ts +147 -15
  84. package/src/scripts/lint_mandated_lines.ts +339 -0
  85. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  86. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  87. package/src/scripts/lint_settings_classes.ts +397 -0
  88. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  89. package/src/scripts/report_imperative_density.ts +229 -0
  90. package/src/scripts/verify_physical_move.ts +10 -1
  91. package/src/scripts/worktree_cleanup_check.ts +319 -4
  92. package/src/server/io/yamlIO.ts +173 -0
  93. package/src/shared/settingsClasses.ts +235 -0
@@ -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));
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Ledger adoption across the CI-registered gate estate.
4
+ *
5
+ * `_lib/gate_ledger.ts` makes a gate's per-target accounting explicit, so that
6
+ * "found nothing" and "looked at almost nothing" stop being the same green
7
+ * line. A library nobody adopts changes nothing, and a 220-gate sweep in one
8
+ * change is exactly the unreviewable diff that gets rubber-stamped — so
9
+ * adoption is a **ratchet**, not a deadline: today's un-adopted count is
10
+ * recorded, and it may only go down.
11
+ *
12
+ * A gate satisfies this check by either
13
+ *
14
+ * 1. importing `./_lib/gate_ledger.js`, or
15
+ * 2. carrying a `// ledger-exempt: <reason>` marker naming why per-target
16
+ * accounting does not apply to it.
17
+ *
18
+ * The marker is deliberately cheap to write and expensive to write *badly*: an
19
+ * empty or near-empty reason is a hard failure rather than an exemption,
20
+ * because a marker that explains nothing is a suppression wearing a
21
+ * justification's clothes — the gate-fatigue failure this repository has
22
+ * already recorded.
23
+ *
24
+ * **Gaming risk** (per the authoring discipline this roadmap introduces): the
25
+ * cheapest degenerate pass is to sprinkle `// ledger-exempt:` markers with
26
+ * plausible-sounding boilerplate, which drops the count without improving a
27
+ * single gate. Mitigations, both partial and stated as such: the minimum
28
+ * reason length below rejects the laziest form, and the marker text lands in
29
+ * the diff where a reviewer sees it. What this gate CANNOT do is judge whether
30
+ * a reason is true; that stays a human read.
31
+ *
32
+ * **Lifecycle.** This gate lands ADVISORY over its existing population: the 217
33
+ * un-adopted gates are recorded as a baseline, not as 217 reds. It is already
34
+ * ENFORCED against growth from day one — a new gate must adopt or exempt.
35
+ * Promote to error over the whole population when the
36
+ * `check_gate_completeness` entry in `src/config/gate-violation-baselines.json`
37
+ * reaches 0.
38
+ *
39
+ * CLI contract: exit 0 = at or under the ratchet, 1 = regressed, marker
40
+ * malformed, or scope dead. `--quiet` suppresses the per-gate listing.
41
+ */
42
+ import * as fs from 'node:fs';
43
+ import * as path from 'node:path';
44
+ import { fileURLToPath, pathToFileURL } from 'node:url';
45
+
46
+ import { checkRatchet } from './_lib/gate_baseline.js';
47
+ import { GateLedger } from './_lib/gate_ledger.js';
48
+ import { renderCoverage } from './_lib/measured_render.js';
49
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
50
+ import { load_tasks, local_closure } from './check_ci_local_parity.js';
51
+
52
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
53
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
54
+ const QUIET = process.argv.slice(2).includes('--quiet');
55
+
56
+ /** Task roots whose transitive closure defines "registered in the CI task list". */
57
+ const TASK_ROOTS = ['ci', 'consistency'] as const;
58
+
59
+ /** The marker that opts a gate out of per-target accounting. */
60
+ export const EXEMPT_MARKER = '// ledger-exempt:';
61
+
62
+ /**
63
+ * Shortest reason accepted after the marker.
64
+ *
65
+ * Not a quality bar — no length can be one. It rejects the specific degenerate
66
+ * form this check invites: `// ledger-exempt: n/a`.
67
+ */
68
+ export const MIN_REASON_CHARS = 20;
69
+
70
+ export interface GateCompletenessRow {
71
+ id: string;
72
+ status: 'ledgered' | 'exempt' | 'unledgered' | 'malformed_exemption';
73
+ reason?: string;
74
+ }
75
+
76
+ const LEDGER_IMPORT_RE = /^import\s.*from\s+'\.\/_lib\/gate_ledger\.js';/m;
77
+ /**
78
+ * The marker must OPEN its own line.
79
+ *
80
+ * An unanchored match reads the marker out of prose — this gate's own
81
+ * docblock, which names the marker to explain it, exempted this gate from
82
+ * itself on the first run. A checker that its own documentation can silence is
83
+ * the false-green shape it exists to remove, so the anchor is load-bearing and
84
+ * is pinned by a fixture.
85
+ */
86
+ const EXEMPT_RE = /^[ \t]*\/\/[ \t]*ledger-exempt:(.*)$/m;
87
+
88
+ /** Classify one gate source. */
89
+ export function classifyGateSource(id: string, source: string): GateCompletenessRow {
90
+ if (LEDGER_IMPORT_RE.test(source)) {
91
+ return { id, status: 'ledgered' };
92
+ }
93
+ const m = EXEMPT_RE.exec(source);
94
+ if (m === null) {
95
+ return { id, status: 'unledgered' };
96
+ }
97
+ const reason = (m[1] ?? '').trim();
98
+ if (reason.length < MIN_REASON_CHARS) {
99
+ return { id, status: 'malformed_exemption', reason };
100
+ }
101
+ return { id, status: 'exempt', reason };
102
+ }
103
+
104
+ /** Every gate id reachable from the CI task roots, script present or not. */
105
+ export function registeredGateIds(repoRoot = REPO_ROOT): string[] {
106
+ return [...local_closure(TASK_ROOTS, load_tasks(repoRoot))].sort();
107
+ }
108
+
109
+ /**
110
+ * Classify every registered gate, accounting for the ones with no script.
111
+ *
112
+ * This gate dogfoods the ledger it enforces. The `.ts`-exists filter used to
113
+ * live in {@link registeredGateIds} as a silent `filter`, which meant a task
114
+ * pointing at a deleted script simply vanished from the denominator — the same
115
+ * invisible-skip shape the ledger exists to name. Now it is an out-of-scope
116
+ * outcome with a reason, and it prints.
117
+ */
118
+ export function auditGateCompleteness(repoRoot = REPO_ROOT): {
119
+ rows: GateCompletenessRow[];
120
+ ledger: GateLedger;
121
+ } {
122
+ const ledger = new GateLedger('check_gate_completeness');
123
+ const ids = registeredGateIds(repoRoot);
124
+ ledger.plan(ids);
125
+ const rows: GateCompletenessRow[] = [];
126
+ for (const id of ids) {
127
+ const file = path.join(repoRoot, 'src', 'scripts', `${id}.ts`);
128
+ if (!fs.existsSync(file)) {
129
+ ledger.outOfScope(id, 'manifest_absent');
130
+ continue;
131
+ }
132
+ const row = classifyGateSource(id, fs.readFileSync(file, 'utf-8'));
133
+ rows.push(row);
134
+ if (row.status === 'malformed_exemption') {
135
+ ledger.fail(id, 'exemption marker carries no usable reason');
136
+ } else {
137
+ ledger.complete(id);
138
+ }
139
+ }
140
+ return { rows, ledger };
141
+ }
142
+
143
+ export function main(): number {
144
+ const { rows, ledger } = auditGateCompleteness();
145
+
146
+ try {
147
+ assertScanned({
148
+ gate: 'check_gate_completeness',
149
+ scanned: rows.length,
150
+ units: 'registered gate(s)',
151
+ roots: ['Taskfile.yml → src/scripts'],
152
+ });
153
+ } catch (exc) {
154
+ if (exc instanceof DeadScopeError) {
155
+ process.stderr.write(`❌ ${exc.message}\n`);
156
+ return 1;
157
+ }
158
+ throw exc;
159
+ }
160
+
161
+ const malformed = rows.filter((r) => r.status === 'malformed_exemption');
162
+ const unledgered = rows.filter((r) => r.status === 'unledgered');
163
+ const exempt = rows.filter((r) => r.status === 'exempt');
164
+ const ledgered = rows.filter((r) => r.status === 'ledgered');
165
+
166
+ // The denominator on the green path — auditable without waiting for a red —
167
+ // and the GAP LIST beside it. A coverage number published alone lets the
168
+ // reader assume the remainder is small and boring; naming it costs one line.
169
+ process.stdout.write(
170
+ `gate completeness: ${String(ledgered.length)} ledgered · ${String(exempt.length)} exempt · ` +
171
+ `${String(unledgered.length)} un-adopted · ${String(rows.length)} registered\n`,
172
+ );
173
+ process.stdout.write(
174
+ `${renderCoverage({
175
+ label: ' ledger coverage',
176
+ covered: ledgered.length + exempt.length,
177
+ total: rows.length,
178
+ gaps: unledgered.map((r) => r.id),
179
+ maxGaps: 6,
180
+ })}\n`,
181
+ );
182
+ ledger.report();
183
+ process.stdout.write(`scanned: ${String(rows.length)}\n`);
184
+
185
+ if (malformed.length > 0) {
186
+ process.stderr.write(
187
+ `\n❌ ${String(malformed.length)} exemption marker(s) carry no usable reason ` +
188
+ `(minimum ${String(MIN_REASON_CHARS)} characters). An exemption that explains ` +
189
+ 'nothing is a suppression:\n',
190
+ );
191
+ for (const row of malformed) {
192
+ process.stderr.write(` - ${row.id}: "${row.reason ?? ''}"\n`);
193
+ }
194
+ return 1;
195
+ }
196
+
197
+ const verdict = checkRatchet({
198
+ gate: 'check_gate_completeness',
199
+ actual: unledgered.length,
200
+ repoRoot: REPO_ROOT,
201
+ });
202
+
203
+ if (!QUIET && unledgered.length > 0 && verdict.status !== 'within') {
204
+ process.stdout.write('\nun-adopted gates:\n');
205
+ for (const row of unledgered) {
206
+ process.stdout.write(` - ${row.id}\n`);
207
+ }
208
+ }
209
+
210
+ if (verdict.ok) {
211
+ if (!QUIET) {
212
+ process.stdout.write(`\n⚠️ ${verdict.message}\n`);
213
+ }
214
+ return 0;
215
+ }
216
+ process.stderr.write(`\n❌ ${verdict.message}\n`);
217
+ return 1;
218
+ }
219
+
220
+ function _isCliEntry(): boolean {
221
+ if (process.argv[1] === undefined) {
222
+ return false;
223
+ }
224
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
225
+ if (import.meta.url === argvUrl) {
226
+ return true;
227
+ }
228
+ // A symlinked invocation (installed projection, or macOS /var → /private/var
229
+ // temp dirs) makes the raw URLs differ: compare realpaths so the entry guard
230
+ // still fires instead of silently no-opping.
231
+ try {
232
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
233
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
234
+ return here === argv;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ if (_isCliEntry() || process.argv[1] === _HERE) {
241
+ process.exitCode = main();
242
+ }
243
+
244
+ export { QUIET, TASK_ROOTS };