@event4u/agent-config 14.2.0 → 14.4.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +286 -218
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3684 -1719
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +16 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- 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
|
+
}
|