@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -20,21 +20,28 @@
20
20
  *
21
21
  * TWO CHECKS, ONE GATE
22
22
  * --------------------
23
- * **T2, the ratchet.** The committed baseline in
24
- * `src/config/estate-count-budget.json` walks down only. Growth in any gated
25
- * count is a policy failure (exit 1) that names the metric, the baseline and the
26
- * live number. A count BELOW the baseline is ALSO a failure (exit 1) — an
27
- * un-walked tightening — because a lower measurement becomes the new ceiling
28
- * rather than becoming unused headroom, and a gate that only warned about it is
29
- * how the headroom survived to be spent. The one exemption is a metric raised in
30
- * the same change with a recorded reason AND a new baseline equal to the live
31
- * count; an over-raise fails here rather than in the next change.
23
+ * **T2, the ratchet.** The floor is not stored — it is MEASURED on the base
24
+ * ref's own tree, with the same functions that measure HEAD. Growth in any gated
25
+ * count above that floor is a policy failure (exit 1) naming the metric, the
26
+ * floor and the live number. A count BELOW the floor is a drawdown and simply
27
+ * passes: there is no stored number left to leave stale.
32
28
  *
33
- * Corrected 2026-08-19. This paragraph documented the below-baseline case as
34
- * green and pointed at `check_preamble_payload_budget` for the shape, while
35
- * `check_estate_count.test.ts` asserted the committed budget EQUALS the live
36
- * tree — so the gate, its own test, and this header described three positions on
37
- * one state.
29
+ * Changed 2026-08-22 (ADR-243). Until then the floor lived in
30
+ * `src/config/estate-count-budget.json` as a committed `baseline` object, and
31
+ * that made the file the most-conflicted non-generated path in the repository —
32
+ * 7 of 7 `CONFLICTING` open PRs on 2026-08-21, and 39 of 43 non-merge commits in
33
+ * a 60-day window moving it. Two whole failure classes came from the storage
34
+ * rather than from the policy, and both are now unrepresentable: a baseline left
35
+ * ABOVE the truth (headroom a later change could spend, which this file used to
36
+ * fail on as an "un-walked tightening"), and a baseline left BELOW it when main
37
+ * archived a roadmap without walking the number, which reddened every branch
38
+ * that merged main. The budget file's own final history entry recorded exactly
39
+ * that incident.
40
+ *
41
+ * What the storage did buy was an authorisation path for legitimate growth: a
42
+ * raise carrying a recorded reason. That is replaced by
43
+ * `estate_growth_exempt: <reason>`, a frontmatter line that must be ADDED in the
44
+ * change that needs it — see § THE THREE WAYS THE ESTATE MAY LEGITIMATELY GROW.
38
45
  *
39
46
  * **T3, one-in-one-out.** A change that adds an active roadmap archives, parks
40
47
  * or merges one in the same change — or the added file carries an explicit
@@ -43,6 +50,62 @@
43
50
  * reads `<base>...HEAD` over `agents/roadmaps/`, so on a branch that adds no
44
51
  * roadmap it legitimately finds nothing to weigh.
45
52
  *
53
+ * THE THREE WAYS THE ESTATE MAY LEGITIMATELY GROW
54
+ * -----------------------------------------------
55
+ * A floor measured off the base ref is exact, so unlike a lagging stored number
56
+ * it leaves no incidental headroom. Every legitimate increase therefore needs a
57
+ * named path, and there are three:
58
+ *
59
+ * 1. **An addition that cannot be offset** — `estate_offset_exempt: <reason>` in
60
+ * the added roadmap's frontmatter, unchanged from T3. It now also raises the
61
+ * `active_roadmaps` allowance by one, because under an exact floor the
62
+ * addition would otherwise fail the count half with no green path.
63
+ * 2. **Parking** — a roadmap moved from the active top level into `later/` in the
64
+ * same change raises the `later_roadmaps` allowance by one. Parking relocates
65
+ * estate rather than creating it, so it needs no authorisation; what the
66
+ * allowance does NOT cover is a `later/` file appearing from nowhere.
67
+ * 3. **Anything else** — `estate_growth_exempt: <reason>` added, in this change,
68
+ * to the frontmatter of any roadmap under `agents/roadmaps/`. The canonical
69
+ * case is a blocker discovered while doing the work: `open_blockers` rises,
70
+ * nothing was archived, and the reason belongs next to the blocker.
71
+ *
72
+ * Two properties of that third path are load-bearing. It is **diff-scoped**: the
73
+ * line must be ADDED between base and HEAD, so a claim cannot be left in a file
74
+ * to authorise every later change — the banking failure a stored baseline had.
75
+ * And it is deliberately **unbounded**, which the stored baseline could not
76
+ * afford: a raise there had to land exactly on the measurement or the surplus was
77
+ * inherited by the next change, where nobody could act on it. Here the next
78
+ * change's floor IS this change's measurement, so there is no surplus to inherit
79
+ * and a bound would buy nothing.
80
+ *
81
+ * WHY THE FLOOR IS MAIN'S TIP AND NOT THE MERGE BASE
82
+ * --------------------------------------------------
83
+ * The floor is measured at the base ref's TIP, so a branch that is behind main
84
+ * reads as growth until it syncs. That is deliberate, and the alternative is
85
+ * worse: against the merge base, two branches each adding one roadmap are both
86
+ * green and main ends up two higher than either of them ever measured. A ratchet
87
+ * whose floor is a common ancestor is not a ratchet on the trunk.
88
+ *
89
+ * On a GitHub PR build this costs nothing — the checkout IS the merge result and
90
+ * `resolveBaseRef` returns `HEAD^1`, the base tip, so the comparison is
91
+ * merged-tree against current main. Locally, a stale branch reads as growth it
92
+ * did not cause, and the remedy is the sync it already owed. Note what that
93
+ * replaced: the stored baseline produced the same obligation as a MERGE CONFLICT
94
+ * in a config file, resolved by hand, four times in one afternoon on
95
+ * 2026-08-21 — and resolved wrongly it silently returned another change's
96
+ * drawdown.
97
+ *
98
+ * WHY A MISSING BASE REF FAILS RATHER THAN SKIPS
99
+ * ---------------------------------------------
100
+ * `resolveBaseRef` has a ladder (origin/main, then the merge commit's first
101
+ * parent) precisely because the obvious answer is unavailable on a shallow PR
102
+ * build. When it still resolves nothing there is no floor at all, and a
103
+ * shrink-only gate with no floor passes everything — so this exits 1 and says
104
+ * so, rather than reporting a green it cannot back. `--base <ref>` is the
105
+ * explicit escape, and it is what the self-test fixtures use. Deliberately NOT
106
+ * gated on `CI`/`GITHUB_ACTIONS`: a gate that convicts on the runner and waves
107
+ * locally teaches contributors that its red is an environment artefact.
108
+ *
46
109
  * WHY THE COUNTS ARE READ FROM THE DASHBOARD'S OWN PARSER
47
110
  * -------------------------------------------------------
48
111
  * `collect()` from `update_roadmap_progress` is the function the dashboard is
@@ -53,6 +116,11 @@
53
116
  * them could disagree with the dashboard, and then the reader has to decide
54
117
  * which one lied.
55
118
  *
119
+ * The same function measures the BASE side, over a scratch copy of the base
120
+ * ref's `agents/roadmaps/` (`_lib/base_tree.ts`). Re-deriving the floor with a
121
+ * second implementation would let the two sides disagree about one tree, which
122
+ * is the failure this section already argues against, one level up.
123
+ *
56
124
  * `later/` is counted from the filesystem because parked roadmaps are
57
125
  * deliberately outside `collect()`'s corpus — they are estate all the same, and
58
126
  * a drawdown that moved everything into `later/` and called the number down
@@ -83,11 +151,20 @@ import {
83
151
  import { reportScanned, DeadScopeError } from './_lib/scan_scope.js';
84
152
  import { GateLedger } from './_lib/gate_ledger.js';
85
153
  import { resolveBaseRef } from './_lib/ratchet_base_ref.js';
154
+ import { materialiseSubtree } from './_lib/base_tree.js';
86
155
  import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
87
156
 
88
157
  const GATE = 'check_estate_count';
89
158
  const BUDGET_REL = path.join('src', 'config', 'estate-count-budget.json');
90
159
  const ROADMAPS_REL = path.join('agents', 'roadmaps');
160
+ /**
161
+ * The same path as a git pathspec.
162
+ *
163
+ * `ROADMAPS_REL` is `path.sep`-joined for filesystem reads; a pathspec must be
164
+ * POSIX or it matches nothing on Windows — silently, and a pathspec matching
165
+ * nothing is how the floor becomes zero.
166
+ */
167
+ const ROADMAPS_POSIX = 'agents/roadmaps';
91
168
 
92
169
  /** The three gated counts. Each is a metric the budget file defines in prose. */
93
170
  export interface EstateCounts {
@@ -101,13 +178,6 @@ export type MetricName = keyof EstateCounts;
101
178
  export const METRICS: readonly MetricName[] = ['active_roadmaps', 'later_roadmaps', 'open_blockers'];
102
179
 
103
180
  export interface EstateBudget {
104
- baseline: EstateCounts;
105
- /**
106
- * The audit trail a raise is read from. Each entry records the metrics it set
107
- * and WHY in a real sentence; a raise whose newest entry carries no `why`, or
108
- * whose `why` does not name the metric's new value, is refused.
109
- */
110
- baseline_history?: Array<Partial<EstateCounts> & { at?: string; why?: string }>;
111
181
  /**
112
182
  * `applies_above_active: null` ⇒ one-in-one-out is unconditional.
113
183
  *
@@ -120,11 +190,14 @@ export interface EstateBudget {
120
190
  one_in_one_out: { applies_above_active: number | null };
121
191
  }
122
192
 
123
- /** One growth finding — a gated count that rose above its committed baseline. */
193
+ /** One gated count that rose above the floor measured at the base ref. */
124
194
  export interface GrowthFinding {
125
195
  metric: MetricName;
126
- baseline: number;
196
+ /** The base ref's own measurement — never a number anyone typed. */
197
+ floor: number;
127
198
  live: number;
199
+ /** Growth this change may make without an `estate_growth_exempt` claim. */
200
+ allowance: number;
128
201
  }
129
202
 
130
203
  /** How a change touched the active roadmap tree, as git reports it. */
@@ -135,44 +208,45 @@ export interface OffsetLedger {
135
208
  offsets: string[];
136
209
  /** Added files carrying an `estate_offset_exempt:` reason, with the reason. */
137
210
  exempt: Array<{ file: string; reason: string }>;
211
+ /**
212
+ * The subset of `offsets` that went to `later/`.
213
+ *
214
+ * Tracked separately because parking is the one offset that RAISES another
215
+ * gated count: active falls by one and later rises by one, which under an
216
+ * exact floor is growth in `later_roadmaps` unless this allowance exists.
217
+ * Kept out of a general "any offset raises any allowance" rule on purpose —
218
+ * an archived roadmap must not buy a new `later/` file.
219
+ */
220
+ parked: string[];
138
221
  }
139
222
 
140
- /**
141
- * A baseline number this change RAISED, and whether it carries its reason.
142
- *
143
- * The whole point of a ratchet is that the change under review cannot silence it
144
- * by editing the number, so the "before" side is read from the base ref with
145
- * `git show <baseRef>:<path>` — the one reading of the baseline this commit
146
- * cannot rewrite. Same argument `_lib/ratchet_base_ref`'s header makes for entry
147
- * sets, applied to a count.
148
- */
149
- export interface RaiseFinding {
150
- metric: MetricName;
151
- from: number;
152
- to: number;
153
- /** A raise WITH a reason is legal and reported; without one it fails. */
154
- reason: string | null;
223
+ /** An `estate_growth_exempt:` reason ADDED to a roadmap in this change. */
224
+ export interface GrowthClaim {
225
+ /** The roadmap the claim was added to, as git reports the path. */
226
+ file: string;
227
+ reason: string;
155
228
  }
156
229
 
157
230
  export interface EstateVerdict {
158
231
  counts: EstateCounts;
159
- baseline: EstateCounts;
232
+ /** The base ref's measurement, or `null` when it could not be taken. */
233
+ floor: EstateCounts | null;
234
+ /** The ref the floor was measured at, for the reader and for `--json`. */
235
+ floorRef: string | null;
236
+ /** `null` when the floor was measured; a stated reason otherwise. */
237
+ floorSkipReason: string | null;
238
+ /** Growth with no allowance and no claim covering it — this is what fails. */
160
239
  growth: GrowthFinding[];
161
240
  /**
162
- * Counts strictly below their baseline — every one of them, exempt or not.
163
- * Kept whole so a `--json` consumer can still tell "nothing is below
164
- * baseline" from "something is, and a bounded reasoned raise exempted it".
165
- */
166
- tightened: GrowthFinding[];
167
- /**
168
- * The subset of `tightened` that FAILS: below baseline with no bounded
169
- * reasoned raise covering it. This is what `withinBudget` reads.
241
+ * Growth an `estate_growth_exempt` claim authorised.
242
+ *
243
+ * Reported rather than dropped: a `--json` consumer must be able to tell
244
+ * "nothing grew" from "something grew and a claim covered it", which is the
245
+ * same distinction the old verdict kept `tightened` whole for.
170
246
  */
171
- unwalked: GrowthFinding[];
172
- /** Baselines raised against the base ref, each with its reason or `null`. */
173
- raises: RaiseFinding[];
174
- /** `null` when the raise check could not run; the reason is printed either way. */
175
- raiseSkipReason: string | null;
247
+ authorisedGrowth: GrowthFinding[];
248
+ /** The claims found in this change's diff, with their reasons. */
249
+ claims: GrowthClaim[];
176
250
  /** `null` when the diff half could not run; the reason is printed either way. */
177
251
  offsets: OffsetLedger | null;
178
252
  offsetSkipReason: string | null;
@@ -299,6 +373,11 @@ function isDisposed(rel: string): boolean {
299
373
  return /^agents\/roadmaps\/(archive|later|skipped|stubs)\//.test(norm);
300
374
  }
301
375
 
376
+ /** A roadmap parked for later — the one disposition that grows another count. */
377
+ function isParked(rel: string): boolean {
378
+ return rel.split(path.sep).join('/').startsWith('agents/roadmaps/later/');
379
+ }
380
+
302
381
  /**
303
382
  * Read the exemption reason a newly added roadmap declares, if any.
304
383
  *
@@ -329,6 +408,7 @@ export function classifyDiff(nameStatus: string, readFile: (rel: string) => stri
329
408
  const added: string[] = [];
330
409
  const offsets: string[] = [];
331
410
  const exempt: Array<{ file: string; reason: string }> = [];
411
+ const parked: string[] = [];
332
412
  for (const line of nameStatus.split('\n')) {
333
413
  if (line.trim() === '') continue;
334
414
  const cols = line.split('\t');
@@ -338,6 +418,7 @@ export function classifyDiff(nameStatus: string, readFile: (rel: string) => stri
338
418
  const to = cols[2] ?? '';
339
419
  if (isActiveTopLevel(from) && isDisposed(to)) {
340
420
  offsets.push(from);
421
+ if (isParked(to)) parked.push(to);
341
422
  } else if (isDisposed(from) && isActiveTopLevel(to)) {
342
423
  added.push(to);
343
424
  }
@@ -359,7 +440,42 @@ export function classifyDiff(nameStatus: string, readFile: (rel: string) => stri
359
440
  exempt.push({ file, reason });
360
441
  }
361
442
  }
362
- return { added, offsets, exempt };
443
+ return { added, offsets, exempt, parked };
444
+ }
445
+
446
+ /**
447
+ * The `estate_growth_exempt:` reasons this change ADDS, read from the patch.
448
+ *
449
+ * Read from the diff rather than from the file, and that is the point: a claim
450
+ * sitting in a roadmap authorises nothing on a later change, so an exemption
451
+ * cannot be banked the way surplus in a stored baseline could. It also means a
452
+ * newly added roadmap and an edited one need no separate handling — in a
453
+ * `base...HEAD` patch both arrive as `+` lines.
454
+ *
455
+ * `--unified=0` keeps context lines out, so a claim that merely sits NEAR an
456
+ * edited line is not read as added. Deliberately tolerant of leading whitespace
457
+ * and of quoted values, matching `exemptionReason`; deliberately NOT tolerant of
458
+ * an empty reason, because an exemption whose reason is blank is the silent
459
+ * exception the key exists to replace.
460
+ */
461
+ export function growthClaims(patch: string): GrowthClaim[] {
462
+ const out: GrowthClaim[] = [];
463
+ let file = '';
464
+ for (const line of patch.split('\n')) {
465
+ // `+++ b/<path>` names the file the following `+` lines belong to. The
466
+ // `/dev/null` form is a deletion, which cannot carry a claim.
467
+ const head = /^\+\+\+ b\/(.+)$/.exec(line);
468
+ if (head !== null) {
469
+ file = (head[1] ?? '').trim();
470
+ continue;
471
+ }
472
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
473
+ const m = /^\+\s*estate_growth_exempt:\s*(.+?)\s*$/.exec(line);
474
+ if (m === null) continue;
475
+ const reason = (m[1] ?? '').trim().replace(/^["']|["']$/g, '').trim();
476
+ if (reason !== '') out.push({ file, reason });
477
+ }
478
+ return out;
363
479
  }
364
480
 
365
481
  export function evaluate(
@@ -373,67 +489,36 @@ export function evaluate(
373
489
  } catch (err) {
374
490
  throw new Error(`${BUDGET_REL} is unreadable: ${(err as Error).message}`);
375
491
  }
376
- if (budget.baseline === undefined) {
377
- throw new Error(`${BUDGET_REL} carries no "baseline" object.`);
492
+ // The file no longer carries a number, so it carries only policy — and a
493
+ // missing policy object is still a could-not-run rather than a pass. A gate
494
+ // that treats an unreadable config as "nothing to enforce" is the silent-pass
495
+ // shape this file argues against everywhere else.
496
+ if (budget.one_in_one_out === undefined) {
497
+ throw new Error(`${BUDGET_REL} carries no "one_in_one_out" object.`);
378
498
  }
379
499
 
380
500
  const counts = countEstate(repoRoot);
381
- const growth: GrowthFinding[] = [];
382
- const tightened: GrowthFinding[] = [];
383
- for (const metric of METRICS) {
384
- const baseline = budget.baseline[metric];
385
- const live = counts[metric];
386
- if (typeof baseline !== 'number') {
387
- throw new Error(`${BUDGET_REL} baseline is missing the "${metric}" metric.`);
388
- }
389
- if (live > baseline) growth.push({ metric, baseline, live });
390
- else if (live < baseline) tightened.push({ metric, baseline, live });
391
- }
392
-
393
501
  const baseRef = opts.baseRef ?? resolveBaseRef(repoRoot);
394
502
 
395
- // The RAISE check — the half that makes this a ratchet rather than a number.
396
- //
397
- // Comparing the live count against a baseline the same commit may edit is not
398
- // a ratchet: the cheapest way past the growth check above is to type a bigger
399
- // number. So the "before" side is read from the base ref, which is the one
400
- // reading of the baseline this change cannot rewrite, and a raise is legal
401
- // only when the newest `baseline_history` entry carries a real reason AND
402
- // records the metric at its new value.
403
- const raises: RaiseFinding[] = [];
404
- let raiseSkipReason: string | null = null;
503
+ // The FLOOR — measured, never read. `git show <ref>:<path>` gave the old
504
+ // stored number the one property that made it a ratchet: the change under
505
+ // review cannot rewrite the base side. The base ref's TREE has that same
506
+ // property, and unlike a number nobody has to remember to update it.
507
+ let floor: EstateCounts | null = null;
508
+ let floorSkipReason: string | null = null;
405
509
  if (baseRef === null || baseRef === undefined) {
406
- raiseSkipReason = 'no base ref resolved — a baseline raise cannot be detected, so the ratchet half is unproven on this run';
510
+ floorSkipReason =
511
+ 'no base ref resolved (no origin/main, no merge-commit parent) — the floor cannot be measured';
407
512
  } else {
408
- const show = git(['show', `${baseRef}:${BUDGET_REL.split(path.sep).join('/')}`], repoRoot);
409
- if (!show.ok) {
410
- // Absent at base and mistyped-path look identical, so this is stated
411
- // rather than assumed either way. On the commit that INTRODUCES the
412
- // budget this is the correct and expected reading.
413
- raiseSkipReason = `${BUDGET_REL} does not exist at ${baseRef} — treated as the introducing change, so no raise is possible`;
414
- } else {
415
- let baseBudget: EstateBudget | null = null;
416
- try {
417
- baseBudget = JSON.parse(show.stdout) as EstateBudget;
418
- } catch {
419
- baseBudget = null;
420
- }
421
- if (baseBudget === null || baseBudget.baseline === undefined) {
422
- raiseSkipReason = `${BUDGET_REL} at ${baseRef} is unparseable — raise undetectable`;
513
+ const base = materialiseSubtree(repoRoot, baseRef, ROADMAPS_POSIX);
514
+ try {
515
+ if (base.error !== null || base.files === 0) {
516
+ floorSkipReason = `could not read ${ROADMAPS_POSIX} at ${baseRef} — ${base.error ?? 'no files'}`;
423
517
  } else {
424
- const newest = (budget.baseline_history ?? []).at(-1) ?? {};
425
- for (const metric of METRICS) {
426
- const before = baseBudget.baseline[metric];
427
- const after = budget.baseline[metric];
428
- if (typeof before !== 'number' || after <= before) continue;
429
- const why = typeof newest.why === 'string' ? newest.why.trim() : '';
430
- // The reason must belong to THIS raise: an entry that records a
431
- // different number is an older reason being reused, which is the
432
- // silent-reset shape RATCHET_RESET_KEY's header warns about.
433
- const namesMetric = newest[metric] === after;
434
- raises.push({ metric, from: before, to: after, reason: why !== '' && namesMetric ? why : null });
435
- }
518
+ floor = countEstate(base.root);
436
519
  }
520
+ } finally {
521
+ fs.rmSync(base.root, { recursive: true, force: true });
437
522
  }
438
523
  }
439
524
 
@@ -442,6 +527,7 @@ export function evaluate(
442
527
  // argues against, and the count half above still ran.
443
528
  let offsets: OffsetLedger | null = null;
444
529
  let offsetSkipReason: string | null = null;
530
+ const claims: GrowthClaim[] = [];
445
531
  if (baseRef === null || baseRef === undefined) {
446
532
  offsetSkipReason = 'no base ref resolved (no origin/main, no merge-commit parent) — one-in-one-out not evaluated';
447
533
  } else {
@@ -460,6 +546,11 @@ export function evaluate(
460
546
  }
461
547
  });
462
548
  }
549
+ const patch = git(
550
+ ['diff', '--unified=0', '--find-renames', `${baseRef}...HEAD`, '--', ROADMAPS_REL],
551
+ repoRoot,
552
+ );
553
+ if (patch.ok) claims.push(...growthClaims(patch.stdout));
463
554
  }
464
555
 
465
556
  const threshold = budget.one_in_one_out?.applies_above_active ?? null;
@@ -471,77 +562,55 @@ export function evaluate(
471
562
  unpaid = Math.max(0, chargeable.length - offsets.offsets.length);
472
563
  }
473
564
 
474
- // A metric RAISED in this same change with a recorded reason is a deliberate
475
- // ceiling rather than an un-walked tightening — the raise is the reviewed act
476
- // the ratchet exists to permit, and without an exemption the failure below
477
- // would make a legal raise unsatisfiable, since a raise puts the baseline
478
- // above the live count by construction.
479
- //
480
- // BOUNDED at `to === live`, and the bound is the whole of it. Round 2 finding
481
- // 1: unbounded, the exemption re-opened the defect this change exists to
482
- // close, one commit later. A raise could bank arbitrary headroom and exit 0;
483
- // the NEXT change carries no raise, so nothing exempts the still-below-
484
- // baseline metric, and that change plus main fail on drift they did not
485
- // cause. Requiring the raise to land ON the measurement means every legal
486
- // state leaves `baseline == live`, so a later change cannot inherit the debt —
487
- // and an over-raise fails in the change that types it, which is the only
488
- // place anyone can act on it.
489
- //
490
- // A raise recorded WITHOUT a reason is not exempt at all; it already fails on
491
- // its own account below, and exempting it here would let the reason
492
- // requirement be skipped by overshooting.
493
- const boundedRaise = new Set(
494
- raises.filter((r) => r.reason !== null && r.to === counts[r.metric]).map((r) => r.metric),
495
- );
496
-
497
- // When the raise half could not run, the information that separates a
498
- // deliberate ceiling from forgotten headroom is ABSENT, so this check reports
499
- // rather than convicts — the same permissive reading the raise half itself
500
- // takes on the same missing input (round 2 finding 4). Failing here while the
501
- // raise half skips would refuse a legal reasoned raise with no green path,
502
- // and would treat one missing fact two opposite ways inside one gate.
503
- const tighteningProvable = raiseSkipReason === null;
504
- const unwalked = tighteningProvable ? tightened.filter((t) => !boundedRaise.has(t.metric)) : [];
565
+ // The allowances, one metric at a time and never a blanket rule. An exempt
566
+ // addition and a parked roadmap are the two increases the existing policy
567
+ // already sanctions, and under an exact floor each needs its own headroom or
568
+ // the sanctioned act has no green path. `open_blockers` gets none: nothing in
569
+ // the policy sanctions a new blocker, so it takes the claim path or nothing.
570
+ const allowance: Record<MetricName, number> = {
571
+ active_roadmaps: offsets?.exempt.length ?? 0,
572
+ later_roadmaps: offsets?.parked.length ?? 0,
573
+ open_blockers: 0,
574
+ };
575
+
576
+ const over: GrowthFinding[] = [];
577
+ if (floor !== null) {
578
+ for (const metric of METRICS) {
579
+ const live = counts[metric];
580
+ const allowed = floor[metric] + allowance[metric];
581
+ if (live > allowed) {
582
+ over.push({ metric, floor: floor[metric], live, allowance: allowance[metric] });
583
+ }
584
+ }
585
+ }
586
+ // A claim authorises the growth in THIS change and nothing beyond it. It is
587
+ // read from the patch, so it cannot be inherited; see `growthClaims`.
588
+ const authorisedGrowth = claims.length > 0 ? over : [];
589
+ const growth = claims.length > 0 ? [] : over;
505
590
 
506
591
  return {
507
592
  counts,
508
- baseline: budget.baseline,
593
+ floor,
594
+ floorRef: baseRef ?? null,
595
+ floorSkipReason,
509
596
  growth,
510
- tightened,
511
- unwalked,
512
- raises,
513
- raiseSkipReason,
597
+ authorisedGrowth,
598
+ claims,
514
599
  offsets,
515
600
  offsetSkipReason,
516
601
  unpaid,
517
- // `tightened` is a FAILURE, not an advisory, and this is the second thing
518
- // it has been. Until 2026-08-19 it printed "free tightening" and left
519
- // `withinBudget` true, while `check_estate_count.test.ts` asserted the
520
- // committed budget equals the live tree — so the gate and its own test
521
- // disagreed about the same state. The consequence was measured rather than
522
- // theorised: a drawdown PR passed `task preflight` locally (this gate,
523
- // exit 0) and reddened CI (that test, exit 1) every single time, and on
524
- // 2026-08-18 it reddened `main` itself in run 32173675197, after which
525
- // every subsequent PR failed on drift it had not caused. That is the
526
- // permanently-red shape the budget file's own `67 -> 69` history entry
527
- // describes from the other direction.
528
- //
529
- // Failing here is also the STRONGER reading of this gate's purpose: the
530
- // file says in as many words that a lower measurement is the new ceiling
531
- // and not headroom to spend later. A baseline left above the truth is
532
- // exactly that headroom, and a gate that only warns about it is how the
533
- // headroom survives to be spent.
534
- withinBudget:
535
- growth.length === 0 &&
536
- unpaid === 0 &&
537
- unwalked.length === 0 &&
538
- raises.every((r) => r.reason !== null),
602
+ // No floor is a FAILURE, not a skip, and it is the one place this gate
603
+ // convicts on a missing input. The other halves can report "unproven" and
604
+ // still leave a meaningful verdict behind; the count half IS the verdict,
605
+ // so with no floor a green here would assert something nothing measured.
606
+ // A shrink-only gate whose floor is absent passes every possible tree.
607
+ withinBudget: floorSkipReason === null && growth.length === 0 && unpaid === 0,
539
608
  };
540
609
  }
541
610
 
542
611
  /** Floors for `--self-test`, declared here so a truncation is a visible diff. */
543
- const SELF_TEST_MIN_CASES = 9;
544
- const SELF_TEST_MIN_REJECT = 6;
612
+ const SELF_TEST_MIN_CASES = 12;
613
+ const SELF_TEST_MIN_REJECT = 8;
545
614
 
546
615
  /**
547
616
  * Prove, on demand, that this gate's rejections still fire against its own CLI.
@@ -557,24 +626,35 @@ function selfTest(): number {
557
626
  const script = path.join('src', 'scripts', 'check_estate_count.ts');
558
627
  const roots: string[] = [];
559
628
 
560
- /** A budget body; `why` is what makes a raise against the base ref legal. */
561
- const budgetJson = (active: number, later: number, why?: string): string => {
562
- const baseline = { active_roadmaps: active, later_roadmaps: later, open_blockers: 0 };
563
- return `${JSON.stringify(
564
- {
565
- baseline,
566
- ...(why === undefined ? {} : { baseline_history: [{ at: 'fixture', ...baseline, why }] }),
567
- one_in_one_out: { applies_above_active: null },
568
- },
569
- null,
570
- 4,
571
- )}\n`;
572
- };
629
+ /**
630
+ * The budget body — POLICY ONLY since ADR-243.
631
+ *
632
+ * There is no number in it any more, which is why the raise cases the old
633
+ * self-test carried are gone rather than ported: with nothing stored, "raise
634
+ * the baseline with no reason" is not a state a change can reach.
635
+ */
636
+ const budgetJson = (): string =>
637
+ `${JSON.stringify({ one_in_one_out: { applies_above_active: null } }, null, 4)}\n`;
638
+
639
+ const roadmap = (name: string, extra = ''): string =>
640
+ `# Roadmap: ${name}\n\n## Phase 1\n\n- [ ] **1.1** s\n${extra}`;
641
+
642
+ /** An open blocker — no `Status: resolved` prefix, so it counts as open. */
643
+ const BLOCKER = '\n## Blockers\n\n### Blocker: fixture\n\n- **Status:** open\n';
573
644
 
574
645
  const fixture = (opts: {
575
646
  roadmaps: number;
576
- baseline: { active: number; later: number };
577
647
  after: (dir: string) => void;
648
+ base?: string;
649
+ /**
650
+ * Build the repo with NO trunk branch, which is the only way to reach the
651
+ * no-floor case: `resolveBaseRef` falls back to a LOCAL `main` after
652
+ * origin/main, so merely omitting `--base` still resolves one. Measured —
653
+ * the first version of this case expected a reject and got exit 0.
654
+ */
655
+ trunkless?: boolean;
656
+ /** Runs before the BASE commit, so a case can seed the base tree. */
657
+ before?: (dir: string) => void;
578
658
  }): number => {
579
659
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ec-selftest-'));
580
660
  roots.push(dir);
@@ -585,21 +665,23 @@ function selfTest(): number {
585
665
  const g = (...args: string[]): void => {
586
666
  spawnSync('git', args, { cwd: dir, encoding: 'utf-8' });
587
667
  };
588
- g('init', '-q', '-b', 'main');
668
+ g('init', '-q', '-b', opts.trunkless === true ? 'wip' : 'main');
589
669
  g('config', 'user.email', 'selftest@local');
590
670
  g('config', 'user.name', 'selftest');
591
671
  g('config', 'commit.gpgsign', 'false');
592
672
  for (let i = 0; i < opts.roadmaps; i++) {
593
- put(`agents/roadmaps/road-to-${String(i)}.md`, `# Roadmap: R${String(i)}\n\n## Phase 1\n\n- [ ] **1.1** s\n`);
673
+ put(`agents/roadmaps/road-to-${String(i)}.md`, roadmap(`R${String(i)}`));
594
674
  }
595
- put('src/config/estate-count-budget.json', budgetJson(opts.baseline.active, opts.baseline.later));
675
+ put('src/config/estate-count-budget.json', budgetJson());
676
+ opts.before?.(dir);
596
677
  g('add', '-A');
597
678
  g('commit', '-qm', 'base');
598
679
  g('checkout', '-qb', 'feat/x');
599
680
  opts.after(dir);
600
681
  g('add', '-A');
601
682
  g('commit', '-qm', 'change');
602
- return runGateCli(repo, script, ['--base', 'main'], dir);
683
+ const argv = opts.base === undefined ? [] : ['--base', opts.base];
684
+ return runGateCli(repo, script, argv, dir);
603
685
  };
604
686
 
605
687
  const write = (dir: string, rel: string, body: string): void => {
@@ -607,131 +689,184 @@ function selfTest(): number {
607
689
  fs.writeFileSync(path.join(dir, rel), body, 'utf-8');
608
690
  };
609
691
 
692
+ const mv = (dir: string, from: string, to: string): void => {
693
+ fs.mkdirSync(path.join(dir, path.dirname(to)), { recursive: true });
694
+ spawnSync('git', ['mv', from, to], { cwd: dir, encoding: 'utf-8' });
695
+ };
696
+
610
697
  const cases: SelfTestCase[] = [
611
698
  {
612
- name: 'baseline match → accept',
699
+ name: 'estate unchanged against the base tree → accept',
613
700
  expect: 'accept',
614
- run: () => fixture({ roadmaps: 3, baseline: { active: 3, later: 0 }, after: () => undefined }),
701
+ run: () => fixture({ roadmaps: 3, base: 'main', after: () => undefined }),
615
702
  },
616
703
  {
617
- name: 'active count grows past the baseline → reject',
704
+ // The floor is now exact, so this case no longer depends on anyone
705
+ // having typed the right number at the base commit.
706
+ name: 'active count grows above the base-tree floor → reject',
618
707
  expect: 'reject',
619
708
  run: () =>
620
709
  fixture({
621
710
  roadmaps: 3,
622
- baseline: { active: 3, later: 0 },
623
- after: (dir) => write(dir, 'agents/roadmaps/road-to-new.md', '# Roadmap: N\n\n## Phase 1\n\n- [ ] **1.1** s\n'),
711
+ base: 'main',
712
+ after: (dir) => write(dir, 'agents/roadmaps/road-to-new.md', roadmap('N')),
624
713
  }),
625
714
  },
626
715
  {
627
- name: 'addition with no offset in the same change → reject',
716
+ // Isolating the one-in-one-out half needs the COUNT half satisfied,
717
+ // and under an exact floor the only way to add a roadmap and stay at
718
+ // the floor is a claim. So the claim is what buys the isolation the
719
+ // old fixture bought with a pre-raised baseline.
720
+ name: 'addition with a growth claim but no offset → reject (one-in-one-out)',
628
721
  expect: 'reject',
629
722
  run: () =>
630
723
  fixture({
631
- // Baseline is 4 AT THE BASE COMMIT, not raised on the branch, so
632
- // the ratchet and raise halves both pass and only the offset
633
- // half can fail — otherwise this case would pass for another
634
- // check's reason.
635
724
  roadmaps: 3,
636
- baseline: { active: 4, later: 0 },
637
- after: (dir) => write(dir, 'agents/roadmaps/road-to-new.md', '# Roadmap: N\n\n## Phase 1\n\n- [ ] **1.1** s\n'),
725
+ base: 'main',
726
+ after: (dir) =>
727
+ write(
728
+ dir,
729
+ 'agents/roadmaps/road-to-new.md',
730
+ '---\nestate_growth_exempt: fixture — isolates one-in-one-out from the count half\n---\n\n' +
731
+ roadmap('N'),
732
+ ),
733
+ }),
734
+ },
735
+ {
736
+ name: 'addition carrying estate_offset_exempt → accept',
737
+ expect: 'accept',
738
+ run: () =>
739
+ fixture({
740
+ roadmaps: 3,
741
+ base: 'main',
742
+ after: (dir) =>
743
+ write(
744
+ dir,
745
+ 'agents/roadmaps/road-to-new.md',
746
+ '---\nestate_offset_exempt: fixture — the addition that cannot be offset\n---\n\n' + roadmap('N'),
747
+ ),
748
+ }),
749
+ },
750
+ {
751
+ name: 'addition paid for by an archive move → accept',
752
+ expect: 'accept',
753
+ run: () =>
754
+ fixture({
755
+ roadmaps: 3,
756
+ base: 'main',
757
+ after: (dir) => {
758
+ write(dir, 'agents/roadmaps/road-to-new.md', roadmap('N'));
759
+ mv(dir, 'agents/roadmaps/road-to-2.md', 'agents/roadmaps/archive/road-to-2.md');
760
+ },
761
+ }),
762
+ },
763
+ {
764
+ // Parking is the case an exact floor would otherwise forbid outright:
765
+ // active falls by one and later rises by one, and without the parking
766
+ // allowance the sanctioned drawdown action fails the gate.
767
+ name: 'parking a roadmap into later/ → accept',
768
+ expect: 'accept',
769
+ run: () =>
770
+ fixture({
771
+ roadmaps: 3,
772
+ base: 'main',
773
+ after: (dir) => mv(dir, 'agents/roadmaps/road-to-2.md', 'agents/roadmaps/later/road-to-2.md'),
638
774
  }),
639
775
  },
640
776
  {
641
- // The bypass an R2 review found in the first version: the estate is
642
- // untouched and only the NUMBER moves, which satisfies the growth half
643
- // by construction. Proven at the CLI, because the working-tree read
644
- // that allowed it was in the CLI's own evaluate().
645
- name: 'baseline raised with no recorded reason → reject',
777
+ // The other side of that allowance, and the reason it is keyed on
778
+ // parking rather than on "any offset": a later/ file that nothing was
779
+ // parked into is estate arriving for free.
780
+ name: 'a later/ roadmap appearing from nowhere → reject',
646
781
  expect: 'reject',
647
782
  run: () =>
648
783
  fixture({
649
784
  roadmaps: 3,
650
- baseline: { active: 3, later: 0 },
651
- after: (dir) => write(dir, 'src/config/estate-count-budget.json', budgetJson(9, 9)),
785
+ base: 'main',
786
+ after: (dir) => write(dir, 'agents/roadmaps/later/road-to-parked.md', roadmap('P')),
652
787
  }),
653
788
  },
654
789
  {
655
- // Round 2 finding 1: this case used to expect ACCEPT, which pinned an
656
- // over-raise of +6 as legal. A reason makes a raise reviewable; it does
657
- // not make banked headroom safe, and the banked headroom is inherited by
658
- // the next change, where nobody can act on it.
659
- name: 'raise WITH a reason but overshooting the measurement → reject',
790
+ name: 'a new open blocker with no claim → reject',
660
791
  expect: 'reject',
661
792
  run: () =>
662
793
  fixture({
663
794
  roadmaps: 3,
664
- baseline: { active: 3, later: 0 },
665
- after: (dir) =>
666
- write(
667
- dir,
668
- 'src/config/estate-count-budget.json',
669
- budgetJson(9, 9, 'fixture: a deliberate re-baseline, recorded so the ratchet can see it'),
670
- ),
795
+ base: 'main',
796
+ after: (dir) => write(dir, 'agents/roadmaps/road-to-1.md', roadmap('R1', BLOCKER)),
671
797
  }),
672
798
  },
673
799
  {
674
- // The green path the bound must leave open, or the exemption would be
675
- // unsatisfiable and a legal raise impossible. The addition carries
676
- // `estate_offset_exempt` so the one-in-one-out half cannot be the reason
677
- // this case passes or fails.
678
- name: 'raise WITH a reason landing ON the measurement → accept',
800
+ name: 'a new open blocker WITH a growth claim → accept',
679
801
  expect: 'accept',
680
802
  run: () =>
681
803
  fixture({
682
804
  roadmaps: 3,
683
- baseline: { active: 3, later: 0 },
684
- after: (dir) => {
685
- write(
686
- dir,
687
- 'agents/roadmaps/road-to-new.md',
688
- '---\nestate_offset_exempt: fixture — isolates the raise half from one-in-one-out\n---\n\n# Roadmap: N\n\n## Phase 1\n\n- [ ] **1.1** s\n',
689
- );
805
+ base: 'main',
806
+ after: (dir) =>
690
807
  write(
691
808
  dir,
692
- 'src/config/estate-count-budget.json',
693
- budgetJson(4, 0, 'fixture: raised to the measurement, which is what a bounded raise looks like'),
694
- );
695
- },
809
+ 'agents/roadmaps/road-to-1.md',
810
+ '---\nestate_growth_exempt: fixture — a blocker discovered while doing the work\n---\n\n' +
811
+ roadmap('R1', BLOCKER),
812
+ ),
696
813
  }),
697
814
  },
698
815
  {
699
- // Round 2 finding 11: the rejection class this change adds had no
700
- // contributor-facing case, so the truncation floor could not notice it
701
- // going missing — which is the whole job of this self-test.
702
- name: 'count below baseline with no raise → reject (un-walked tightening)',
816
+ // An exemption whose reason is blank is the silent exception the key
817
+ // exists to replace, so a bare marker must not authorise anything.
818
+ name: 'a growth claim with an empty reason → reject',
703
819
  expect: 'reject',
704
820
  run: () =>
705
821
  fixture({
706
822
  roadmaps: 3,
707
- baseline: { active: 3, later: 0 },
708
- after: (dir) => fs.rmSync(path.join(dir, 'agents/roadmaps/road-to-2.md')),
823
+ base: 'main',
824
+ after: (dir) =>
825
+ write(dir, 'agents/roadmaps/road-to-1.md', '---\nestate_growth_exempt:\n---\n\n' + roadmap('R1', BLOCKER)),
709
826
  }),
710
827
  },
711
828
  {
712
- name: 'addition paid for by an archive move → accept',
713
- expect: 'accept',
829
+ // The anti-banking property, and the reason claims are read from the
830
+ // PATCH rather than from the file. A claim sitting in a roadmap at the
831
+ // base commit authorises nothing here: a stored baseline could be
832
+ // over-raised once and spent by every later change, and the whole
833
+ // point of reading the diff is that this shape has no equivalent.
834
+ name: 'a claim already present at base authorises nothing → reject',
835
+ expect: 'reject',
714
836
  run: () =>
715
837
  fixture({
716
838
  roadmaps: 3,
717
- baseline: { active: 3, later: 0 },
718
- after: (dir) => {
719
- write(dir, 'agents/roadmaps/road-to-new.md', '# Roadmap: N\n\n## Phase 1\n\n- [ ] **1.1** s\n');
720
- fs.mkdirSync(path.join(dir, 'agents/roadmaps/archive'), { recursive: true });
721
- spawnSync('git', ['mv', 'agents/roadmaps/road-to-2.md', 'agents/roadmaps/archive/road-to-2.md'], {
722
- cwd: dir,
723
- encoding: 'utf-8',
724
- });
725
- },
839
+ base: 'main',
840
+ before: (dir) =>
841
+ write(
842
+ dir,
843
+ 'agents/roadmaps/road-to-1.md',
844
+ '---\nestate_growth_exempt: fixture — banked at base, must not carry over\n---\n\n' +
845
+ roadmap('R1'),
846
+ ),
847
+ after: (dir) =>
848
+ write(
849
+ dir,
850
+ 'agents/roadmaps/road-to-1.md',
851
+ '---\nestate_growth_exempt: fixture — banked at base, must not carry over\n---\n\n' +
852
+ roadmap('R1', BLOCKER),
853
+ ),
726
854
  }),
727
855
  },
856
+ {
857
+ // The class that replaces the old "raise with no reason" case: with no
858
+ // stored number, the way to silence this gate is to deny it a floor.
859
+ name: 'no base ref resolvable → reject (no floor is not a pass)',
860
+ expect: 'reject',
861
+ run: () => fixture({ roadmaps: 3, trunkless: true, after: () => undefined }),
862
+ },
728
863
  {
729
864
  name: 'emptied roadmap root → reject (dead scope, not a pass)',
730
865
  expect: 'reject',
731
866
  run: () =>
732
867
  fixture({
733
868
  roadmaps: 1,
734
- baseline: { active: 0, later: 0 },
869
+ base: 'main',
735
870
  after: (dir) => fs.rmSync(path.join(dir, 'agents/roadmaps/road-to-0.md')),
736
871
  }),
737
872
  },
@@ -804,25 +939,19 @@ export function main(argv: string[] = process.argv.slice(2)): number {
804
939
  }
805
940
 
806
941
  const ledger = new GateLedger(GATE);
807
- ledger.plan([...METRICS, 'baseline_raise', 'one_in_one_out']);
942
+ ledger.plan([...METRICS, 'floor', 'one_in_one_out']);
808
943
  for (const metric of METRICS) {
944
+ // Round 2 finding 3, kept: a metric is recorded ONCE here, for whichever
945
+ // way it actually failed, so the ledger cannot show a green completeness
946
+ // record beside a red exit. With no floor there is no per-metric verdict
947
+ // to record at all, which is a skip rather than a completion.
809
948
  const bad = verdict.growth.find((g) => g.metric === metric);
810
- // Round 2 finding 3: the un-walked-tightening class exited 1 while this
811
- // loop reported every metric complete, so the ledger — the stderr audit
812
- // surface, and the only one `--json` carries — showed a fully green
813
- // completeness record beside a red exit. A metric is recorded once, here,
814
- // for whichever way it actually failed.
815
- const stale = verdict.unwalked.find((t) => t.metric === metric);
816
- if (bad !== undefined) ledger.fail(metric, `${metric} grew ${bad.baseline} → ${bad.live}`);
817
- else if (stale !== undefined)
818
- ledger.fail(metric, `${metric} un-walked: ${stale.live} under a baseline of ${stale.baseline}`);
949
+ if (verdict.floor === null) ledger.skip(metric, 'precondition_unmet');
950
+ else if (bad !== undefined) ledger.fail(metric, `${metric} grew ${bad.floor} → ${bad.live}`);
819
951
  else ledger.complete(metric);
820
952
  }
821
- const unreasoned = verdict.raises.filter((r) => r.reason === null);
822
- if (verdict.raiseSkipReason !== null) ledger.skip('baseline_raise', 'precondition_unmet');
823
- else if (unreasoned.length > 0)
824
- ledger.fail('baseline_raise', `${String(unreasoned.length)} raise(s) with no recorded reason`);
825
- else ledger.complete('baseline_raise');
953
+ if (verdict.floorSkipReason !== null) ledger.fail('floor', verdict.floorSkipReason);
954
+ else ledger.complete('floor');
826
955
  if (verdict.offsets === null) ledger.skip('one_in_one_out', 'precondition_unmet');
827
956
  else if (verdict.unpaid > 0) ledger.fail('one_in_one_out', `${String(verdict.unpaid)} unpaid addition(s)`);
828
957
  else ledger.complete('one_in_one_out');
@@ -838,63 +967,61 @@ export function main(argv: string[] = process.argv.slice(2)): number {
838
967
 
839
968
  for (const metric of METRICS) {
840
969
  const live = verdict.counts[metric];
841
- const base = verdict.baseline[metric];
970
+ const base = verdict.floor?.[metric];
971
+ if (base === undefined) {
972
+ process.stdout.write(` ${metric.padEnd(18)} ${String(live).padStart(5)} (floor unmeasured)\n`);
973
+ continue;
974
+ }
842
975
  const delta = live - base;
843
976
  const sign = delta >= 0 ? '+' : '';
844
977
  process.stdout.write(
845
- ` ${metric.padEnd(18)} ${String(live).padStart(5)} (baseline ${String(base)}, ${sign}${String(delta)})\n`,
978
+ ` ${metric.padEnd(18)} ${String(live).padStart(5)} (floor ${String(base)} at ${verdict.floorRef ?? '?'}, ${sign}${String(delta)})\n`,
846
979
  );
847
980
  }
848
981
  if (verdict.offsets !== null) {
849
982
  const o = verdict.offsets;
850
983
  process.stdout.write(
851
984
  ` ${'this change'.padEnd(18)} +${String(o.added.length)} active / -${String(o.offsets.length)} disposed` +
985
+ `${o.parked.length > 0 ? `, ${String(o.parked.length)} parked` : ''}` +
852
986
  `${o.exempt.length > 0 ? `, ${String(o.exempt.length)} exempt` : ''}\n`,
853
987
  );
854
988
  } else if (verdict.offsetSkipReason !== null) {
855
989
  process.stdout.write(` ⚠️ ${verdict.offsetSkipReason}\n`);
856
990
  }
857
- if (verdict.raiseSkipReason !== null) {
858
- process.stdout.write(` ⚠️ ${verdict.raiseSkipReason}\n`);
991
+ for (const c of verdict.claims) {
992
+ process.stdout.write(` ↑ growth claimed in ${c.file}: ${c.reason.slice(0, 120)}\n`);
859
993
  }
860
- for (const r of verdict.raises.filter((x) => x.reason !== null)) {
994
+ for (const g of verdict.authorisedGrowth) {
861
995
  process.stdout.write(
862
- ` ↑ baseline raised with a recorded reason: ${r.metric} ${String(r.from)} → ${String(r.to)}\n` +
863
- ` ${(r.reason as string).slice(0, 120)}\n`,
996
+ ` ↑ ${g.metric} grew ${String(g.floor)} → ${String(g.live)}, authorised by the claim above\n`,
864
997
  );
865
998
  }
866
999
 
867
- for (const t of verdict.unwalked) {
1000
+ if (verdict.floorSkipReason !== null) {
868
1001
  process.stderr.write(
869
- `❌ un-walked tightening: ${t.metric} measured ${String(t.live)} under a baseline of ${String(t.baseline)}.\n` +
870
- ` Walk the baseline down in ${BUDGET_REL} — a lower measurement is the new ceiling,\n` +
871
- ' not headroom to spend later. This is a one-number edit plus one appended\n' +
872
- ' `baseline_history` entry, and it belongs in the change that earned the lower\n' +
873
- ' measurement. Failing here rather than warning is deliberate: this gate and\n' +
874
- " `check_estate_count.test.ts` used to disagree about this exact state, so a\n" +
875
- ' drawdown passed preflight and reddened CI — and once it reached the trunk,\n' +
876
- ' every later PR failed on drift it had not caused.\n',
1002
+ `❌ no floor: ${verdict.floorSkipReason}.\n` +
1003
+ " This gate compares the estate against the base ref's own tree, so with no base\n" +
1004
+ ' ref there is nothing to compare against — and a shrink-only gate with no floor\n' +
1005
+ ' passes every possible tree, which is why this is a failure and not a warning.\n' +
1006
+ ' Fetch the base (`git fetch origin main`) or name it explicitly:\n' +
1007
+ ' ./scripts-run src/scripts/check_estate_count --base origin/main\n',
877
1008
  );
878
1009
  }
879
1010
 
880
1011
  for (const g of verdict.growth) {
1012
+ const allow = g.allowance > 0 ? ` (+${String(g.allowance)} allowed here)` : '';
881
1013
  process.stderr.write(
882
- `❌ the roadmap estate grew: ${g.metric} ${String(g.baseline)} → ${String(g.live)}.\n` +
883
- ' The estate does not walk down by itself, which is the defect this ratchet\n' +
884
- ` exists for. Either close/park/archive enough to get back under the baseline, or\n` +
885
- ` raise it in ${BUDGET_REL} with the reason written as a real sentence in the\n` +
886
- ' same commit — a number change on its own is what a ratchet is built to refuse.\n',
887
- );
888
- }
889
- for (const r of unreasoned) {
890
- process.stderr.write(
891
- `❌ baseline raised with no recorded reason: ${r.metric} ${String(r.from)} → ${String(r.to)}.\n` +
892
- ` Read from the base ref, so editing the number in this commit cannot hide it —\n` +
893
- ' which is the whole difference between a ratchet and a number. A raise is legal,\n' +
894
- ` and it costs one appended \`baseline_history\` entry in ${BUDGET_REL} carrying\n` +
895
- ` \`"${r.metric}": ${String(r.to)}\` and a \`why\` written as a real sentence.\n`,
1014
+ `❌ the roadmap estate grew: ${g.metric} ${String(g.floor)} → ${String(g.live)}${allow}.\n` +
1015
+ ` The floor is the measurement at ${verdict.floorRef ?? 'the base ref'}, not a number in a\n` +
1016
+ ' config — so there is no number to edit and nothing to walk. The estate does not\n' +
1017
+ ' walk down by itself, which is the defect this ratchet exists for. Either close,\n' +
1018
+ ' park or archive enough to get back to the floor, or claim the growth where it\n' +
1019
+ ' happened: add `estate_growth_exempt: <reason>` to the frontmatter of a roadmap\n' +
1020
+ ' this change touches. The claim is read from the diff, so it authorises this\n' +
1021
+ ' change and no later one, and the reason is a real sentence or it does not count.\n',
896
1022
  );
897
1023
  }
1024
+
898
1025
  if (verdict.unpaid > 0 && verdict.offsets !== null) {
899
1026
  process.stderr.write(
900
1027
  `❌ ${String(verdict.unpaid)} new active roadmap(s) with no offset in the same change.\n` +