@event4u/agent-config 14.2.0 → 14.5.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -252
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3754 -1721
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +127 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +118 -7
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/host_semantics.ts +28 -0
  100. package/src/scripts/hooks/injection_budget.ts +731 -0
  101. package/src/scripts/hooks/payload_stub.ts +446 -0
  102. package/src/scripts/hooks/run_continuation_hook.ts +683 -0
  103. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  104. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  105. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  107. package/src/scripts/hooks_doctor.ts +16 -2
  108. package/src/scripts/interruption_report.ts +330 -13
  109. package/src/scripts/lint_hook_manifest.ts +167 -0
  110. package/src/scripts/roadmap_progress_hook.ts +254 -46
  111. package/src/scripts/run_supervise.ts +623 -0
  112. package/src/scripts/session_register_hook.ts +146 -12
  113. package/src/scripts/sessions_cli.ts +18 -6
  114. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  115. package/src/server/io/yamlIO.ts +167 -3
  116. package/src/shared/settingsConsent.ts +49 -2
  117. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,924 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `check_estate_count` — the roadmap estate walks DOWN, and a new roadmap pays
4
+ * for itself.
5
+ *
6
+ * WHY THIS EXISTS
7
+ * ---------------
8
+ * `road-to-estate-drawdown` § 0 states the defect: the estate carries dozens of
9
+ * active roadmaps and dozens of open blockers, and **the count does not go down
10
+ * by itself**. New roadmaps arrive faster than old ones terminate — the roadmap
11
+ * that registered this campaign was itself adopted in a sitting that added seven
12
+ * files at once, and records that as an instance of the defect rather than an
13
+ * exception to it.
14
+ *
15
+ * Nothing in this tree objected. `lint_roadmap_family_cap` bounds concurrency
16
+ * WITHIN one `road-to-<family>-*` family; `lint_empty_roadmaps` and
17
+ * `check_roadmap_trackable` police individual files. No gate reads the estate as
18
+ * a whole, so growth was invisible to CI and visible only to whoever happened to
19
+ * read the dashboard header.
20
+ *
21
+ * TWO CHECKS, ONE GATE
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.
32
+ *
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.
38
+ *
39
+ * **T3, one-in-one-out.** A change that adds an active roadmap archives, parks
40
+ * or merges one in the same change — or the added file carries an explicit
41
+ * `estate_offset_exempt:` reason in its frontmatter, which costs a visible line
42
+ * in the diff of the very commit that claims it. This half is diff-scoped: it
43
+ * reads `<base>...HEAD` over `agents/roadmaps/`, so on a branch that adds no
44
+ * roadmap it legitimately finds nothing to weigh.
45
+ *
46
+ * WHY THE COUNTS ARE READ FROM THE DASHBOARD'S OWN PARSER
47
+ * -------------------------------------------------------
48
+ * `collect()` from `update_roadmap_progress` is the function the dashboard is
49
+ * generated from, imported rather than re-implemented — the same discipline
50
+ * `roadmap_gates.ts` states for the blocker projection. Verified 2026-08-18:
51
+ * `collect().length` is 38 and its summed open blockers are 49, which are
52
+ * exactly the two numbers the dashboard header prints. A gate that re-derived
53
+ * them could disagree with the dashboard, and then the reader has to decide
54
+ * which one lied.
55
+ *
56
+ * `later/` is counted from the filesystem because parked roadmaps are
57
+ * deliberately outside `collect()`'s corpus — they are estate all the same, and
58
+ * a drawdown that moved everything into `later/` and called the number down
59
+ * would be the burial T1's quality anchor exists to prevent.
60
+ *
61
+ * Invocation (from project root):
62
+ * ./scripts-run src/scripts/check_estate_count
63
+ * ./scripts-run src/scripts/check_estate_count --json
64
+ * ./scripts-run src/scripts/check_estate_count --base origin/main
65
+ *
66
+ * Exit codes: 0 within budget · 1 policy violation (growth, or an unpaid
67
+ * addition) · 2 could-not-run (unreadable budget, dead scan scope).
68
+ */
69
+
70
+ import * as fs from 'node:fs';
71
+ import * as os from 'node:os';
72
+ import * as path from 'node:path';
73
+ import { spawnSync } from 'node:child_process';
74
+ import { fileURLToPath, pathToFileURL } from 'node:url';
75
+
76
+ import {
77
+ collect,
78
+ is_draft as isDraft,
79
+ is_roadmap_candidate as isRoadmapCandidate,
80
+ parse_frontmatter as parseFrontmatter,
81
+ parse_roadmap as parseRoadmap,
82
+ } from '../agent-src/scripts/update_roadmap_progress.js';
83
+ import { reportScanned, DeadScopeError } from './_lib/scan_scope.js';
84
+ import { GateLedger } from './_lib/gate_ledger.js';
85
+ import { resolveBaseRef } from './_lib/ratchet_base_ref.js';
86
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
87
+
88
+ const GATE = 'check_estate_count';
89
+ const BUDGET_REL = path.join('src', 'config', 'estate-count-budget.json');
90
+ const ROADMAPS_REL = path.join('agents', 'roadmaps');
91
+
92
+ /** The three gated counts. Each is a metric the budget file defines in prose. */
93
+ export interface EstateCounts {
94
+ active_roadmaps: number;
95
+ later_roadmaps: number;
96
+ open_blockers: number;
97
+ }
98
+
99
+ export type MetricName = keyof EstateCounts;
100
+
101
+ export const METRICS: readonly MetricName[] = ['active_roadmaps', 'later_roadmaps', 'open_blockers'];
102
+
103
+ 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
+ /**
112
+ * `applies_above_active: null` ⇒ one-in-one-out is unconditional.
113
+ *
114
+ * T3 phrases the condition as "while the active count sits above target",
115
+ * and T1 says the target number belongs to the maintainer. Rather than
116
+ * inventing a ceiling so the condition can be evaluated, `null` states that
117
+ * no ceiling is registered and the lint therefore applies. Registering a
118
+ * number here is how it gets a threshold.
119
+ */
120
+ one_in_one_out: { applies_above_active: number | null };
121
+ }
122
+
123
+ /** One growth finding — a gated count that rose above its committed baseline. */
124
+ export interface GrowthFinding {
125
+ metric: MetricName;
126
+ baseline: number;
127
+ live: number;
128
+ }
129
+
130
+ /** How a change touched the active roadmap tree, as git reports it. */
131
+ export interface OffsetLedger {
132
+ /** Files that entered the active top level: new files, and un-parked ones. */
133
+ added: string[];
134
+ /** Files that left it: deleted, archived, parked or merged away. */
135
+ offsets: string[];
136
+ /** Added files carrying an `estate_offset_exempt:` reason, with the reason. */
137
+ exempt: Array<{ file: string; reason: string }>;
138
+ }
139
+
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;
155
+ }
156
+
157
+ export interface EstateVerdict {
158
+ counts: EstateCounts;
159
+ baseline: EstateCounts;
160
+ growth: GrowthFinding[];
161
+ /**
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.
170
+ */
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;
176
+ /** `null` when the diff half could not run; the reason is printed either way. */
177
+ offsets: OffsetLedger | null;
178
+ offsetSkipReason: string | null;
179
+ unpaid: number;
180
+ withinBudget: boolean;
181
+ }
182
+
183
+ function repoRootFrom(start: string): string {
184
+ let dir = path.resolve(start);
185
+ for (;;) {
186
+ if (fs.existsSync(path.join(dir, ROADMAPS_REL))) {
187
+ return dir;
188
+ }
189
+ const up = path.dirname(dir);
190
+ if (up === dir) {
191
+ return path.resolve(start);
192
+ }
193
+ dir = up;
194
+ }
195
+ }
196
+
197
+ function git(args: readonly string[], cwd: string): { ok: boolean; stdout: string } {
198
+ const res = spawnSync('git', [...args], { cwd, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
199
+ return { ok: res.status === 0, stdout: res.stdout ?? '' };
200
+ }
201
+
202
+ /**
203
+ * Roadmap-shaped `.md` files directly inside `<roadmaps>/<sub>`.
204
+ *
205
+ * The predicate is fed the BARE FILENAME, never the path: `is_roadmap_candidate`
206
+ * excludes any path with an `archive`/`skipped`/`stubs`/`later` component, which
207
+ * is correct for the active walk and would make every file in a disposition
208
+ * directory invisible here. Passing the name keeps the parts that apply —
209
+ * `README.md`, `template.md`, `open-questions*` are not roadmaps in any
210
+ * directory. (First version passed the path and counted 0 of 44.)
211
+ */
212
+ function countIn(roadmapRoot: string, sub: string): number {
213
+ try {
214
+ return fs
215
+ .readdirSync(path.join(roadmapRoot, sub))
216
+ .filter((n) => n.endsWith('.md') && isRoadmapCandidate(n)).length;
217
+ } catch {
218
+ return 0;
219
+ }
220
+ }
221
+
222
+ /** Non-draft roadmaps parked in `later/`, parsed for their blockers. */
223
+ function laterRoadmaps(roadmapRoot: string): Array<{ open_blockers: readonly unknown[] }> {
224
+ const dir = path.join(roadmapRoot, 'later');
225
+ let names: string[];
226
+ try {
227
+ names = fs.readdirSync(dir);
228
+ } catch {
229
+ return [];
230
+ }
231
+ const out: Array<{ open_blockers: readonly unknown[] }> = [];
232
+ for (const name of names) {
233
+ if (!name.endsWith('.md') || !isRoadmapCandidate(name)) continue;
234
+ const abs = path.join(dir, name);
235
+ // `collect()` cannot be reused here: it filters through the same
236
+ // path-based predicate and would reject its own root.
237
+ const text = fs.readFileSync(abs, 'utf-8');
238
+ if (isDraft(parseFrontmatter(text))) continue;
239
+ const stats = parseRoadmap(abs, dir);
240
+ if (stats !== null) out.push(stats as unknown as { open_blockers: readonly unknown[] });
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Count the estate, three ways.
247
+ *
248
+ * `active_roadmaps` comes from `collect()` so it cannot disagree with the
249
+ * dashboard. `later_roadmaps` is a directory listing because parked files are
250
+ * outside that corpus by design — filtered through the same `is_roadmap_candidate`
251
+ * predicate as the active side, so `later/README.md` is not a roadmap here either.
252
+ *
253
+ * `open_blockers` spans the active tree AND `later/`, which is a correction to the
254
+ * first version of this gate: counting it over the active tree alone meant
255
+ * **parking a roadmap dropped the gated blocker count without resolving
256
+ * anything**, and the ratchet then printed "free tightening" over a burial and
257
+ * invited a permanent baseline drop. AC-1 is phrased on this metric, so the metric
258
+ * has to survive a move that resolves nothing. `skipped/` and `archive/` stay out:
259
+ * those are terminal, and a blocker in a skipped roadmap is not open work.
260
+ *
261
+ * **So this metric deliberately does NOT equal the dashboard header's blocker
262
+ * count, which is active-only.** The first version claimed parity with it as a
263
+ * virtue; burial-resistance is worth more here, and claiming both would be the
264
+ * claim that broke. `active_roadmaps` keeps the parity.
265
+ */
266
+ export function countEstate(repoRoot: string): EstateCounts {
267
+ const roadmapRoot = path.join(repoRoot, ROADMAPS_REL);
268
+ const stats = collect(roadmapRoot);
269
+ const openOf = (rows: readonly { open_blockers: readonly unknown[] }[]): number =>
270
+ rows.reduce((n, r) => n + r.open_blockers.length, 0);
271
+ return {
272
+ active_roadmaps: stats.length,
273
+ later_roadmaps: countIn(roadmapRoot, 'later'),
274
+ open_blockers: openOf(stats) + openOf(laterRoadmaps(roadmapRoot)),
275
+ };
276
+ }
277
+
278
+ /** `agents/roadmaps/<name>.md` — the active top level, never a subdirectory. */
279
+ function isActiveTopLevel(rel: string): boolean {
280
+ const norm = rel.split(path.sep).join('/');
281
+ if (!norm.startsWith('agents/roadmaps/') || !norm.endsWith('.md')) {
282
+ return false;
283
+ }
284
+ const tail = norm.slice('agents/roadmaps/'.length);
285
+ return !tail.includes('/') && isRoadmapCandidate(norm);
286
+ }
287
+
288
+ /**
289
+ * A disposition directory — where an offset sends a roadmap.
290
+ *
291
+ * `stubs/` is in the set, and it was missing from the first version. Un-stubbing
292
+ * is the documented promotion path, so a stub moved to the top level is an
293
+ * ADDITION that T3 must charge, and a roadmap demoted to a stub is an offset.
294
+ * With `stubs/` unrecognised, a promotion was classified as neither and the lint
295
+ * could never charge it — the one hole that let an active roadmap arrive for free.
296
+ */
297
+ function isDisposed(rel: string): boolean {
298
+ const norm = rel.split(path.sep).join('/');
299
+ return /^agents\/roadmaps\/(archive|later|skipped|stubs)\//.test(norm);
300
+ }
301
+
302
+ /**
303
+ * Read the exemption reason a newly added roadmap declares, if any.
304
+ *
305
+ * The key lives in the file's own frontmatter rather than in a config or a
306
+ * commit trailer, for the reason `RATCHET_RESET_KEY` gives for living inside the
307
+ * baseline JSON: the claim then shows up in the diff of the change that makes
308
+ * it, and a reviewer sees it without being told to look.
309
+ */
310
+ export function exemptionReason(text: string): string | null {
311
+ const fm = parseFrontmatter(text);
312
+ const raw = (fm as Record<string, unknown>)['estate_offset_exempt'];
313
+ if (typeof raw !== 'string') {
314
+ return null;
315
+ }
316
+ const reason = raw.trim().replace(/^["']|["']$/g, '').trim();
317
+ return reason === '' ? null : reason;
318
+ }
319
+
320
+ /**
321
+ * Classify the change's effect on the active roadmap tree.
322
+ *
323
+ * Renames carry information a name-only diff loses: `road-to-x.md` →
324
+ * `archive/road-to-x.md` is the wanted direction and counts as an offset, while
325
+ * `later/road-to-x.md` → `road-to-x.md` is an un-parking and counts as an
326
+ * addition. A top-level-to-top-level rename is neither.
327
+ */
328
+ export function classifyDiff(nameStatus: string, readFile: (rel: string) => string | null): OffsetLedger {
329
+ const added: string[] = [];
330
+ const offsets: string[] = [];
331
+ const exempt: Array<{ file: string; reason: string }> = [];
332
+ for (const line of nameStatus.split('\n')) {
333
+ if (line.trim() === '') continue;
334
+ const cols = line.split('\t');
335
+ const status = (cols[0] ?? '').trim();
336
+ if (status.startsWith('R') || status.startsWith('C')) {
337
+ const from = cols[1] ?? '';
338
+ const to = cols[2] ?? '';
339
+ if (isActiveTopLevel(from) && isDisposed(to)) {
340
+ offsets.push(from);
341
+ } else if (isDisposed(from) && isActiveTopLevel(to)) {
342
+ added.push(to);
343
+ }
344
+ continue;
345
+ }
346
+ const file = cols[1] ?? '';
347
+ if (!isActiveTopLevel(file)) continue;
348
+ if (status === 'A') {
349
+ added.push(file);
350
+ } else if (status === 'D') {
351
+ offsets.push(file);
352
+ }
353
+ }
354
+ for (const file of added) {
355
+ const text = readFile(file);
356
+ if (text === null) continue;
357
+ const reason = exemptionReason(text);
358
+ if (reason !== null) {
359
+ exempt.push({ file, reason });
360
+ }
361
+ }
362
+ return { added, offsets, exempt };
363
+ }
364
+
365
+ export function evaluate(
366
+ repoRoot: string,
367
+ opts: { baseRef?: string | undefined } = {},
368
+ ): EstateVerdict {
369
+ const budgetPath = path.join(repoRoot, BUDGET_REL);
370
+ let budget: EstateBudget;
371
+ try {
372
+ budget = JSON.parse(fs.readFileSync(budgetPath, 'utf-8')) as EstateBudget;
373
+ } catch (err) {
374
+ throw new Error(`${BUDGET_REL} is unreadable: ${(err as Error).message}`);
375
+ }
376
+ if (budget.baseline === undefined) {
377
+ throw new Error(`${BUDGET_REL} carries no "baseline" object.`);
378
+ }
379
+
380
+ 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
+ const baseRef = opts.baseRef ?? resolveBaseRef(repoRoot);
394
+
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;
405
+ 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';
407
+ } 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`;
423
+ } 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
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ // The diff half. A missing base ref is REPORTED rather than assumed empty:
441
+ // guessing "nothing changed" is the same silent-pass this file's own header
442
+ // argues against, and the count half above still ran.
443
+ let offsets: OffsetLedger | null = null;
444
+ let offsetSkipReason: string | null = null;
445
+ if (baseRef === null || baseRef === undefined) {
446
+ offsetSkipReason = 'no base ref resolved (no origin/main, no merge-commit parent) — one-in-one-out not evaluated';
447
+ } else {
448
+ const diff = git(
449
+ ['diff', '--name-status', '--find-renames', `${baseRef}...HEAD`, '--', ROADMAPS_REL],
450
+ repoRoot,
451
+ );
452
+ if (!diff.ok) {
453
+ offsetSkipReason = `git diff against ${baseRef} failed — one-in-one-out not evaluated`;
454
+ } else {
455
+ offsets = classifyDiff(diff.stdout, (rel) => {
456
+ try {
457
+ return fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
458
+ } catch {
459
+ return null;
460
+ }
461
+ });
462
+ }
463
+ }
464
+
465
+ const threshold = budget.one_in_one_out?.applies_above_active ?? null;
466
+ const lintApplies = threshold === null || counts.active_roadmaps > threshold;
467
+ let unpaid = 0;
468
+ if (offsets !== null && lintApplies) {
469
+ const exemptFiles = new Set(offsets.exempt.map((e) => e.file));
470
+ const chargeable = offsets.added.filter((f) => !exemptFiles.has(f));
471
+ unpaid = Math.max(0, chargeable.length - offsets.offsets.length);
472
+ }
473
+
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)) : [];
505
+
506
+ return {
507
+ counts,
508
+ baseline: budget.baseline,
509
+ growth,
510
+ tightened,
511
+ unwalked,
512
+ raises,
513
+ raiseSkipReason,
514
+ offsets,
515
+ offsetSkipReason,
516
+ 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),
539
+ };
540
+ }
541
+
542
+ /** 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;
545
+
546
+ /**
547
+ * Prove, on demand, that this gate's rejections still fire against its own CLI.
548
+ *
549
+ * The vitest suite covers the same ground; this covers the binary a contributor
550
+ * runs — argv parsing, the entry guard, and the git invocation, which unit tests
551
+ * over imported functions cannot exercise. A ratchet whose detection silently
552
+ * stopped matching reports "estate within its ratchet" forever, and that green is
553
+ * believed precisely because a ratchet is expected to be quiet.
554
+ */
555
+ function selfTest(): number {
556
+ const repo = repoRootFrom(process.cwd());
557
+ const script = path.join('src', 'scripts', 'check_estate_count.ts');
558
+ const roots: string[] = [];
559
+
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
+ };
573
+
574
+ const fixture = (opts: {
575
+ roadmaps: number;
576
+ baseline: { active: number; later: number };
577
+ after: (dir: string) => void;
578
+ }): number => {
579
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ec-selftest-'));
580
+ roots.push(dir);
581
+ const put = (rel: string, body: string): void => {
582
+ fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
583
+ fs.writeFileSync(path.join(dir, rel), body, 'utf-8');
584
+ };
585
+ const g = (...args: string[]): void => {
586
+ spawnSync('git', args, { cwd: dir, encoding: 'utf-8' });
587
+ };
588
+ g('init', '-q', '-b', 'main');
589
+ g('config', 'user.email', 'selftest@local');
590
+ g('config', 'user.name', 'selftest');
591
+ g('config', 'commit.gpgsign', 'false');
592
+ 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`);
594
+ }
595
+ put('src/config/estate-count-budget.json', budgetJson(opts.baseline.active, opts.baseline.later));
596
+ g('add', '-A');
597
+ g('commit', '-qm', 'base');
598
+ g('checkout', '-qb', 'feat/x');
599
+ opts.after(dir);
600
+ g('add', '-A');
601
+ g('commit', '-qm', 'change');
602
+ return runGateCli(repo, script, ['--base', 'main'], dir);
603
+ };
604
+
605
+ const write = (dir: string, rel: string, body: string): void => {
606
+ fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
607
+ fs.writeFileSync(path.join(dir, rel), body, 'utf-8');
608
+ };
609
+
610
+ const cases: SelfTestCase[] = [
611
+ {
612
+ name: 'baseline match → accept',
613
+ expect: 'accept',
614
+ run: () => fixture({ roadmaps: 3, baseline: { active: 3, later: 0 }, after: () => undefined }),
615
+ },
616
+ {
617
+ name: 'active count grows past the baseline → reject',
618
+ expect: 'reject',
619
+ run: () =>
620
+ fixture({
621
+ 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'),
624
+ }),
625
+ },
626
+ {
627
+ name: 'addition with no offset in the same change → reject',
628
+ expect: 'reject',
629
+ run: () =>
630
+ 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
+ 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'),
638
+ }),
639
+ },
640
+ {
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',
646
+ expect: 'reject',
647
+ run: () =>
648
+ fixture({
649
+ roadmaps: 3,
650
+ baseline: { active: 3, later: 0 },
651
+ after: (dir) => write(dir, 'src/config/estate-count-budget.json', budgetJson(9, 9)),
652
+ }),
653
+ },
654
+ {
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',
660
+ expect: 'reject',
661
+ run: () =>
662
+ fixture({
663
+ 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
+ ),
671
+ }),
672
+ },
673
+ {
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',
679
+ expect: 'accept',
680
+ run: () =>
681
+ fixture({
682
+ 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
+ );
690
+ write(
691
+ 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
+ },
696
+ }),
697
+ },
698
+ {
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)',
703
+ expect: 'reject',
704
+ run: () =>
705
+ fixture({
706
+ roadmaps: 3,
707
+ baseline: { active: 3, later: 0 },
708
+ after: (dir) => fs.rmSync(path.join(dir, 'agents/roadmaps/road-to-2.md')),
709
+ }),
710
+ },
711
+ {
712
+ name: 'addition paid for by an archive move → accept',
713
+ expect: 'accept',
714
+ run: () =>
715
+ fixture({
716
+ 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
+ },
726
+ }),
727
+ },
728
+ {
729
+ name: 'emptied roadmap root → reject (dead scope, not a pass)',
730
+ expect: 'reject',
731
+ run: () =>
732
+ fixture({
733
+ roadmaps: 1,
734
+ baseline: { active: 0, later: 0 },
735
+ after: (dir) => fs.rmSync(path.join(dir, 'agents/roadmaps/road-to-0.md')),
736
+ }),
737
+ },
738
+ ];
739
+
740
+ try {
741
+ return runSelfTest({
742
+ gate: GATE,
743
+ cases,
744
+ minCases: SELF_TEST_MIN_CASES,
745
+ minRejectCases: SELF_TEST_MIN_REJECT,
746
+ });
747
+ } finally {
748
+ for (const dir of roots) {
749
+ fs.rmSync(dir, { recursive: true, force: true });
750
+ }
751
+ }
752
+ }
753
+
754
+ export function main(argv: string[] = process.argv.slice(2)): number {
755
+ if (argv.includes('--self-test')) return selfTest();
756
+ const json = argv.includes('--json') || argv.includes('--format=json');
757
+ const baseIdx = argv.indexOf('--base');
758
+ let baseRef: string | undefined;
759
+ if (baseIdx !== -1) {
760
+ const next = argv[baseIdx + 1];
761
+ // `--base --json` used to take `--json` as the ref, which then failed as
762
+ // a git revision and silently downgraded both halves to "unproven".
763
+ if (next === undefined || next.startsWith('-')) {
764
+ process.stderr.write('usage: check_estate_count [--base <ref>] [--json] [--self-test]\n');
765
+ return 2;
766
+ }
767
+ baseRef = next;
768
+ }
769
+ const repoRoot = repoRootFrom(process.cwd());
770
+
771
+ let verdict: EstateVerdict;
772
+ try {
773
+ verdict = evaluate(repoRoot, { baseRef });
774
+ } catch (err) {
775
+ process.stderr.write(`❌ ${GATE}: ${(err as Error).message}\n`);
776
+ return 2;
777
+ }
778
+
779
+ // A ratchet over an empty estate always passes. Move `agents/roadmaps/` and
780
+ // every count is 0, which is trivially under any baseline — exit 2 (could
781
+ // not run), never 1, which would assert the estate actually grew.
782
+ try {
783
+ reportScanned(
784
+ {
785
+ gate: GATE,
786
+ scanned: verdict.counts.active_roadmaps + verdict.counts.later_roadmaps,
787
+ units: 'roadmap file(s)',
788
+ roots: [ROADMAPS_REL, path.join(ROADMAPS_REL, 'later')],
789
+ },
790
+ // In `--json` the line goes to stderr, so stdout really is one JSON
791
+ // document. `reportScanned` defaults to stdout because CI passes
792
+ // `--quiet` to most gates and a count only visible without it is not
793
+ // a count; this gate's CI argv carries no `--json`, so the default
794
+ // path is unaffected. Same override, same reason, as
795
+ // check_review_prompt_binding.
796
+ json ? (chunk: string) => process.stderr.write(chunk) : undefined,
797
+ );
798
+ } catch (err) {
799
+ if (err instanceof DeadScopeError) {
800
+ process.stderr.write(`❌ ${GATE}: ${err.message}\n`);
801
+ return 2;
802
+ }
803
+ throw err;
804
+ }
805
+
806
+ const ledger = new GateLedger(GATE);
807
+ ledger.plan([...METRICS, 'baseline_raise', 'one_in_one_out']);
808
+ for (const metric of METRICS) {
809
+ 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}`);
819
+ else ledger.complete(metric);
820
+ }
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');
826
+ if (verdict.offsets === null) ledger.skip('one_in_one_out', 'precondition_unmet');
827
+ else if (verdict.unpaid > 0) ledger.fail('one_in_one_out', `${String(verdict.unpaid)} unpaid addition(s)`);
828
+ else ledger.complete('one_in_one_out');
829
+
830
+ if (json) {
831
+ // The ledger goes to stderr here so stdout stays parseable after the
832
+ // mandatory `scanned:` line — a JSON mode whose stdout needs a second
833
+ // parser is not a machine-readable mode.
834
+ ledger.report((chunk) => process.stderr.write(chunk));
835
+ process.stdout.write(JSON.stringify(verdict, null, 2) + '\n');
836
+ return verdict.withinBudget ? 0 : 1;
837
+ }
838
+
839
+ for (const metric of METRICS) {
840
+ const live = verdict.counts[metric];
841
+ const base = verdict.baseline[metric];
842
+ const delta = live - base;
843
+ const sign = delta >= 0 ? '+' : '';
844
+ process.stdout.write(
845
+ ` ${metric.padEnd(18)} ${String(live).padStart(5)} (baseline ${String(base)}, ${sign}${String(delta)})\n`,
846
+ );
847
+ }
848
+ if (verdict.offsets !== null) {
849
+ const o = verdict.offsets;
850
+ process.stdout.write(
851
+ ` ${'this change'.padEnd(18)} +${String(o.added.length)} active / -${String(o.offsets.length)} disposed` +
852
+ `${o.exempt.length > 0 ? `, ${String(o.exempt.length)} exempt` : ''}\n`,
853
+ );
854
+ } else if (verdict.offsetSkipReason !== null) {
855
+ process.stdout.write(` ⚠️ ${verdict.offsetSkipReason}\n`);
856
+ }
857
+ if (verdict.raiseSkipReason !== null) {
858
+ process.stdout.write(` ⚠️ ${verdict.raiseSkipReason}\n`);
859
+ }
860
+ for (const r of verdict.raises.filter((x) => x.reason !== null)) {
861
+ 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`,
864
+ );
865
+ }
866
+
867
+ for (const t of verdict.unwalked) {
868
+ 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',
877
+ );
878
+ }
879
+
880
+ for (const g of verdict.growth) {
881
+ 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`,
896
+ );
897
+ }
898
+ if (verdict.unpaid > 0 && verdict.offsets !== null) {
899
+ process.stderr.write(
900
+ `❌ ${String(verdict.unpaid)} new active roadmap(s) with no offset in the same change.\n` +
901
+ ` added: ${verdict.offsets.added.join(', ')}\n` +
902
+ ` disposed: ${verdict.offsets.offsets.length > 0 ? verdict.offsets.offsets.join(', ') : '(none)'}\n` +
903
+ ' One-in-one-out (estate-drawdown T3): a change that adds an active roadmap\n' +
904
+ ' archives, parks or merges one in the same change. If this addition genuinely\n' +
905
+ ' cannot be offset, add `estate_offset_exempt: <reason>` to its frontmatter —\n' +
906
+ ' that costs one reviewable line instead of a silent exception.\n',
907
+ );
908
+ }
909
+ ledger.report();
910
+ if (verdict.withinBudget) {
911
+ process.stdout.write(`✅ ${GATE}: estate within its ratchet.\n`);
912
+ }
913
+ return verdict.withinBudget ? 0 : 1;
914
+ }
915
+
916
+ function isCliEntry(): boolean {
917
+ const entry = process.argv[1];
918
+ if (entry === undefined) return false;
919
+ return pathToFileURL(path.resolve(entry)).href === pathToFileURL(fileURLToPath(import.meta.url)).href;
920
+ }
921
+
922
+ if (isCliEntry()) {
923
+ process.exit(main());
924
+ }