@event4u/agent-config 13.0.0 → 14.1.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 (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +195 -315
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +336 -112
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2128 -1326
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1764 -899
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +5 -5
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gate_ledger.ts +5 -1
  75. package/src/scripts/_lib/gh_transient.ts +80 -0
  76. package/src/scripts/_lib/release_material.ts +16 -5
  77. package/src/scripts/_lib/review_independence.ts +111 -0
  78. package/src/scripts/_lib/session_register.ts +14 -0
  79. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  80. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  81. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  82. package/src/scripts/ai_council/_py_json.ts +96 -0
  83. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  84. package/src/scripts/ai_council/clients.ts +80 -143
  85. package/src/scripts/ai_council/config.ts +25 -11
  86. package/src/scripts/ai_council/probe_store.ts +196 -0
  87. package/src/scripts/ai_council/qualification.ts +425 -0
  88. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  89. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  90. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  91. package/src/scripts/build_archive_index.ts +62 -3
  92. package/src/scripts/capture_skill_catalogue.ts +264 -4
  93. package/src/scripts/check_evaluator_schema.ts +249 -0
  94. package/src/scripts/check_gate_coverage.ts +93 -0
  95. package/src/scripts/check_pr_ci_current.ts +492 -0
  96. package/src/scripts/check_review_schema.ts +195 -0
  97. package/src/scripts/check_rule_activation_census.ts +451 -0
  98. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  99. package/src/scripts/condense.ts +1 -1
  100. package/src/scripts/council_cli.ts +141 -191
  101. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  102. package/src/scripts/git_authorization_hook.ts +186 -1
  103. package/src/scripts/hook_manifest.yaml +72 -5
  104. package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
  105. package/src/scripts/hooks/concern_registry.ts +4 -0
  106. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  107. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  108. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  109. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  110. package/src/scripts/hooks_doctor.ts +112 -0
  111. package/src/scripts/interruption_report.ts +443 -0
  112. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  113. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  114. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  115. package/src/scripts/release.ts +38 -53
  116. package/src/scripts/rule_activation_census.ts +292 -0
  117. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  118. package/src/scripts/schemas/review-findings.schema.json +31 -0
  119. package/src/scripts/self_review_gate.ts +6 -0
  120. package/src/scripts/session_register_hook.ts +30 -1
  121. package/src/scripts/sync_pr_branch.ts +271 -0
  122. package/src/scripts/test_council_qualification.ts +213 -0
  123. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -0,0 +1,1128 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_rule_stub_ceiling — hold a migrated rule at the size its pointer claims.
4
+ *
5
+ * ## The defect
6
+ *
7
+ * 44 of this package's 117 rules carry a line of the shape *"Body migrated to
8
+ * `guideline:X` … Trigger-set above activates this routing on demand"*. The
9
+ * projection half of that pattern is real (`type: manual` → `_is_manual_rule`,
10
+ * `condense.ts`), and the migration ledger (`lint_rule_migration_ledger.ts`)
11
+ * records where each pre-migration heading WENT.
12
+ *
13
+ * **Nothing asserted that a migrated rule stays small.** Migration was executed
14
+ * as an event and never encoded as a state, so those pointers are hand-authored
15
+ * prose no gate reads. The sharpest case, measured at adoption:
16
+ * `src/rules/context-hygiene.md` is 10,988 B and declares its body migrated to
17
+ * `context-hygiene-mechanics.md`, which is 3,491 B — the rule is three times the
18
+ * size of the file it migrated into.
19
+ *
20
+ * The aggregate census ratchet (`check_rule_activation_census`) does not catch
21
+ * this and is not meant to: it pins the CORPUS total with a 2,000-token drift
22
+ * allowance, so one rule growing while another shrinks is a green run. Measured
23
+ * at the time of writing, the corpus sits 411 tokens ABOVE its pinned baseline
24
+ * and that gate is green. Per-rule re-growth under an aggregate cap is the gap.
25
+ *
26
+ * ## What this gate asserts
27
+ *
28
+ * 1. **The pointer's target resolves.** Every `migrated to` / `merged into`
29
+ * target — markdown link href or backticked `skill:` / `guideline:` /
30
+ * `context:` / path token — names a file that exists.
31
+ * 2. **The rule is under its own ceiling.** A per-rule exact-BPE token ceiling
32
+ * read from `src/config/rule-stub-ceilings.json`, generated by
33
+ * `--write-baseline` and never hand-edited. Shrink-only, same posture as the
34
+ * census token axis one level finer.
35
+ *
36
+ * Keyed on the **body**, not the whole file. Frontmatter is measured and
37
+ * published but excluded from the verdict: it is the routing surface, and a
38
+ * rule that gains a trigger would otherwise trip a PROSE ceiling whose
39
+ * failure text tells the author to move prose that did not grow. The census
40
+ * owns the whole-file corpus weight; this gate owns the migrated body.
41
+ * 3. **A raise is a stated decision.** A ceiling above the committed one needs a
42
+ * `history` entry whose `reason` is a real sentence. Raising a ceiling to
43
+ * clear a red is the config-weakening move this repo blocks by construction —
44
+ * and `--write-baseline` enforces that itself: it REFUSES to write a raise
45
+ * that no history entry covers, rather than silently re-anchoring the corpus
46
+ * upward. Without that refusal the assertion would be decorative, because the
47
+ * failure text and the baseline comment both point the operator at exactly
48
+ * that command.
49
+ *
50
+ * ## What it deliberately does NOT assert
51
+ *
52
+ * - **Not which prose should move.** That is `road-to-standing-context-40k`
53
+ * step 2.2. This gate measures and holds a line; it never proposes a deletion,
54
+ * and `preservation-guard` forbids one — a ceiling is satisfied by MOVING.
55
+ * - **Not that a rule at its ceiling is correct.** The ceilings are a
56
+ * floor-plus-residue snapshot taken at adoption. A rule at its ceiling is not
57
+ * thereby right, only not worse.
58
+ * - **Not the pointer's semantic accuracy.** That the target resolves is
59
+ * checkable; that it received the right prose is the migration ledger's
60
+ * completeness property, already gated next door.
61
+ * - **Not a cross-commit ratchet.** The gate reads ONE tree state, so it cannot
62
+ * see the previous baseline. "Shrink-only" therefore means: the tree must sit
63
+ * under the committed ceiling, and every ceiling the history says was RAISED
64
+ * carries a real reason. A ceiling silently lowered — or raised with no history
65
+ * entry at all — is a diff-scoped question this gate does not answer; code
66
+ * review of the baseline diff does. Saying so is the point: implying a
67
+ * cross-commit guarantee it cannot give is the coverage inflation this repo's
68
+ * enforcement audit exists to remove.
69
+ * - **Not a phrasing convention.** The pointer set is cross-checked against
70
+ * `migratedRules()` — the ledger gate's own broad matcher — and a rule that
71
+ * matcher sees while this one cannot parse is reported as a FINDING, never as
72
+ * silence. That is the whole mitigation for keying a gate to hand-authored
73
+ * prose written over months in several phrasings.
74
+ *
75
+ * **Bounded by the ledger, because that matcher is unbounded.** It matches
76
+ * `migrated to` / `merged into` anywhere in the file, so a rule that merely
77
+ * MENTIONS the phrase in prose would otherwise be pulled in and failed with
78
+ * remediation advice that is wrong for a rule which never migrated. A rule
79
+ * with no parseable pointer therefore counts as a finding only when
80
+ * `agents/decisions/rule-migrations/<rule>.yml` exists — the ledger the
81
+ * sibling gate already requires of every genuinely migrated rule. Today the
82
+ * ledger holds exactly the 44, so the two agree; the discriminator exists for
83
+ * the day they do not.
84
+ *
85
+ * ## The floor/residue split is reported, not enforced
86
+ *
87
+ * `--report` splits each migrated rule's retained body into **floor** (what
88
+ * `preservation-guard` requires to stay: Iron Law headings at their level, the
89
+ * fenced blocks inside those sections byte-for-byte, capitalised negation
90
+ * clauses, plus the pointer sentence) and **residue** (everything else). The
91
+ * split is a judgment with a mechanical criterion, and it is published per rule
92
+ * WITH the criterion so a reader can disagree with one row without discarding
93
+ * the total. No gate verdict depends on it — it is Phase 3's handover input.
94
+ *
95
+ * Exit codes: 0 = clean, 1 = violations, 2 = usage.
96
+ */
97
+ import { spawnSync } from 'node:child_process';
98
+ import * as fs from 'node:fs';
99
+ import * as os from 'node:os';
100
+ import * as path from 'node:path';
101
+ import { fileURLToPath, pathToFileURL } from 'node:url';
102
+
103
+ import { GateLedger } from './_lib/gate_ledger.js';
104
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
105
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
106
+ import { gpt_tokens, TIKTOKEN_AVAILABLE } from './_lib/token_count.js';
107
+ import { migratedRules } from './lint_rule_migration_ledger.js';
108
+ import { candidate_paths } from './rule_backlinks.js';
109
+
110
+ const SCRIPT_REL = path.join('src', 'scripts', 'check_rule_stub_ceiling.ts');
111
+ const GATE = 'check_rule_stub_ceiling';
112
+ const BASELINE_REL = path.join('src', 'config', 'rule-stub-ceilings.json');
113
+ const RULES_REL = path.join('src', 'rules');
114
+ /** The sibling gate's per-rule migration ledger — the bound on the broad matcher. */
115
+ const LEDGER_DIR_REL = path.join('agents', 'decisions', 'rule-migrations');
116
+
117
+ /**
118
+ * Floors for the self-test suite, set below the shipped case count on purpose.
119
+ *
120
+ * Eleven cases ship and seven reject. The floors sit at 10/6 so retiring one case
121
+ * that has genuinely stopped describing the gate does not red the suite, while
122
+ * deleting it down to a happy path — the cheapest route to a green self-test —
123
+ * still fails.
124
+ */
125
+ const SELF_TEST_MIN_CASES = 10;
126
+ const SELF_TEST_MIN_REJECT = 6;
127
+
128
+ const ARGV = process.argv.slice(2);
129
+ const QUIET = ARGV.includes('--quiet');
130
+ const WRITE = ARGV.includes('--write-baseline');
131
+ const REPORT = ARGV.includes('--report');
132
+
133
+ /**
134
+ * A raise reason must be a sentence, not the number restated.
135
+ *
136
+ * The floor is a length plus a digit-density test: "raised to 4200" and "4200"
137
+ * both fail, "moved because X landed and the body grew for reason Y" passes.
138
+ * Mechanical, not editorial — a reason can be wrong, which is a review problem.
139
+ */
140
+ const MIN_REASON_CHARS = 40;
141
+
142
+ /** Phrasings that are present but say nothing. Mirrors the ledger gate's denylist. */
143
+ const EMPTY_REASONS = [
144
+ 'n/a',
145
+ 'none',
146
+ 'tbd',
147
+ 'see above',
148
+ 'no reason',
149
+ 'raise',
150
+ 'raised',
151
+ 'update',
152
+ 'updated',
153
+ 'adjusted',
154
+ 'refresh',
155
+ ];
156
+
157
+ /**
158
+ * Matches a migration pointer and captures its target.
159
+ *
160
+ * Two target shapes ship in the corpus: a markdown link, where the href is the
161
+ * target and the label is decorative, and a bare backticked token. Anchored on
162
+ * the verb rather than on `Body …` because one live phrasing is mid-sentence
163
+ * ("Portability body migrated to X. Sync body migrated to Y") and two are
164
+ * soft-wrapped across lines — so this runs over the joined body.
165
+ */
166
+ const POINTER_RE = /\b(?:migrated to|merged into)\s+(?:\[[^\]]*\]\(([^)\s]+)\)|`([^`]+)`)/gi;
167
+
168
+ /** An Iron Law heading at any level, including the numbered variants. */
169
+ const IRON_LAW_HEADING_RE = /^#{1,6}\s+(?:The\s+)?Iron\s+Laws?\b/i;
170
+
171
+ /** A capitalised negation clause — the load-bearing exception denials. */
172
+ const NEGATION_RE = /\b(?:NEVER|NO|NOT|DO NOT)\b/;
173
+
174
+ export interface PointerRow {
175
+ /** Rule stem, e.g. `context-hygiene`. */
176
+ rule: string;
177
+ /** Raw target as written in the pointer. */
178
+ target: string;
179
+ /** Repo-relative path it resolved to, or null when unresolved. */
180
+ resolved: string | null;
181
+ /** Candidate paths tried, for the failure message. */
182
+ tried: string[];
183
+ }
184
+
185
+ export interface RuleRow {
186
+ rule: string;
187
+ /** Exact-BPE tokens of the whole file, frontmatter included — the payload. */
188
+ tokens: number;
189
+ /** Exact-BPE tokens of the body alone. THIS is what the ceiling is keyed on. */
190
+ body_tokens: number;
191
+ /**
192
+ * Tokens of the YAML frontmatter.
193
+ *
194
+ * Reported as its own column so `fm + floor + residue` reconciles against
195
+ * `tokens`. Without it the table's columns do not add up, which invites a
196
+ * reader to distrust every row — and the frontmatter is real payload: it is
197
+ * what the emitter reads to decide whether the rule loads at all.
198
+ */
199
+ frontmatter_tokens: number;
200
+ /** Tokens of lines classified floor by the criterion above. */
201
+ floor_tokens: number;
202
+ /** Tokens of everything else in the body. */
203
+ residue_tokens: number;
204
+ pointers: PointerRow[];
205
+ /** True when `migratedRules()` sees this rule but POINTER_RE parsed no target. */
206
+ unparsed: boolean;
207
+ /** True when the opening `---` never closes — measured as zero, so say so. */
208
+ malformed_frontmatter: boolean;
209
+ }
210
+
211
+ /**
212
+ * Resolve a pointer target to a repo-relative path.
213
+ *
214
+ * Reuses `rule_backlinks.candidate_paths` for the `skill:` / `guideline:` /
215
+ * `context:` / `contract:` / repo-path shapes rather than restating them, and
216
+ * `check_references`'s prefix strategy for relative hrefs rather than inventing
217
+ * a second one. A bare word with no shape falls back to a sibling rule, the one
218
+ * form `brand-consistency` uses ("merged into `brand-source-of-truth`").
219
+ *
220
+ * **Why a relative href is not resolved relative to `src/rules/`.** That was the
221
+ * first implementation and it reported 12 of 44 pointers broken — a false red.
222
+ * Rule bodies are authored for the PROJECTED tree, where `../docs/` is the
223
+ * sibling docs directory; under `src/rules/` the same href points at
224
+ * `src/docs/`, which does not exist. `check_references` already solved this by
225
+ * stripping leading `./` / `../` and trying `[root, dist/agent-src, src,
226
+ * src/agent-src]`, and a second convention here would disagree with the gate
227
+ * consumers already trust. Adopted verbatim.
228
+ */
229
+ export function resolvePointer(root: string, raw: string): { resolved: string | null; tried: string[] } {
230
+ // Anchors are part of the link, never part of the file identity.
231
+ const bare = raw.split('#')[0]!.trim();
232
+ if (bare === '') return { resolved: null, tried: [] };
233
+
234
+ const tried: string[] = [];
235
+
236
+ if (bare.startsWith('.') || bare.includes('/')) {
237
+ const stripped = bare.replace(/^[./]+/, '');
238
+ // Prefix ORDER deviates from check_references on purpose, and it is the
239
+ // one place it does: `dist/agent-src/` is a TRACKED projection, so trying
240
+ // it before `src/` lets a pointer resolve against a stale copy after its
241
+ // real target is deleted and `task sync` is not re-run in the same
242
+ // commit — the gate would report a genuinely broken pointer as resolved.
243
+ // The source of truth is tried first; the projection stays in the list as
244
+ // a last resort so a target that legitimately lives only there still
245
+ // resolves.
246
+ for (const prefix of ['', 'src', 'src/agent-src', 'dist/agent-src']) {
247
+ const rel = path.normalize(path.join(prefix, stripped));
248
+ tried.push(rel);
249
+ if (isFile(root, rel)) return { resolved: rel, tried };
250
+ }
251
+ // Also as written from the rule's own directory, which is how a
252
+ // same-directory sibling link (`brand-source-of-truth.md`) resolves.
253
+ const sibling = path.normalize(path.join(RULES_REL, bare));
254
+ tried.push(sibling);
255
+ if (isFile(root, sibling)) return { resolved: sibling, tried };
256
+ }
257
+
258
+ const shaped = candidate_paths(bare);
259
+ if (shaped !== null) {
260
+ for (const rel of shaped) {
261
+ tried.push(rel);
262
+ if (isFile(root, rel)) return { resolved: rel, tried };
263
+ }
264
+ }
265
+
266
+ // Bare stem → a sibling rule.
267
+ if (/^[a-z0-9][a-z0-9_.-]*$/i.test(bare)) {
268
+ const rel = path.join(RULES_REL, `${bare.replace(/\.md$/, '')}.md`);
269
+ tried.push(rel);
270
+ if (isFile(root, rel)) return { resolved: rel, tried };
271
+ }
272
+
273
+ return { resolved: null, tried };
274
+ }
275
+
276
+ /**
277
+ * A pointer target must be a readable FILE.
278
+ *
279
+ * `existsSync` alone accepts a directory, so a pointer rewritten to
280
+ * `../docs/guidelines` — or a `skill:` target whose `SKILL.md` was deleted while
281
+ * its directory survived — would report "resolves: yes" while naming no
282
+ * document. The whole assertion is that a reader following the pointer finds
283
+ * something to read.
284
+ */
285
+ function isFile(root: string, rel: string): boolean {
286
+ try {
287
+ return fs.statSync(path.join(root, rel)).isFile();
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Split a rule file into its YAML frontmatter and its body.
295
+ *
296
+ * A file that ends AT its closing `---` with no trailing newline has no body,
297
+ * and that is a real answer rather than a parse failure — but it used to be
298
+ * indistinguishable from "no frontmatter found", which would have let such a
299
+ * rule contribute zero residue silently. The two cases are returned apart so the
300
+ * caller can say which one it saw.
301
+ */
302
+ function splitFrontmatter(text: string): {
303
+ frontmatter: string;
304
+ body: string;
305
+ /** False when the opening `---` never closes — a malformed file, not an empty one. */
306
+ parsed: boolean;
307
+ } {
308
+ if (!text.startsWith('---')) return { frontmatter: '', body: text, parsed: true };
309
+ const end = text.indexOf('\n---', 3);
310
+ if (end === -1) return { frontmatter: '', body: text, parsed: false };
311
+ const nl = text.indexOf('\n', end + 1);
312
+ if (nl === -1) return { frontmatter: text, body: '', parsed: true };
313
+ return { frontmatter: text.slice(0, nl + 1), body: text.slice(nl + 1), parsed: true };
314
+ }
315
+
316
+ /**
317
+ * Split a body into floor and residue lines by the stated criterion.
318
+ *
319
+ * Floor: an Iron Law heading; every line of a fenced block opened while inside
320
+ * an Iron Law section; any line carrying a capitalised negation clause; the
321
+ * pointer sentence itself. Everything else is residue.
322
+ *
323
+ * The Iron-Law-section boundary is the next heading at the same level or
324
+ * shallower — a `###` inside an `## Iron Law` stays inside it, which matters
325
+ * because demotion is the norm in these files.
326
+ */
327
+ export function splitFloorResidue(body: string): { floor: string[]; residue: string[] } {
328
+ const floor: string[] = [];
329
+ const residue: string[] = [];
330
+ let ironLevel: number | null = null;
331
+ // The OPENING marker, not a boolean: ``` and ~~~ are not interchangeable.
332
+ // Treating them as one closes the outer fence at the inner opener, and the
333
+ // house `markdown-safe-codeblocks` shape is precisely ``` nested inside ~~~ —
334
+ // so the interchangeable version mis-splits every rule that follows it.
335
+ let fenceMarker: string | null = null;
336
+ // A pointer sentence soft-wraps across lines in the live corpus, which is why
337
+ // `readRules` matches on the joined body. Classifying per line would put the
338
+ // continuation into residue, biasing the split in the direction this
339
+ // roadmap's own premise favours. Continuation runs to the blank line that
340
+ // ends the paragraph.
341
+ let inPointerParagraph = false;
342
+
343
+ for (const line of body.split('\n')) {
344
+ const opener = /^\s*(```|~~~)/.exec(line);
345
+ const heading = /^(#{1,6})\s/.exec(line);
346
+
347
+ if (fenceMarker !== null) {
348
+ (ironLevel !== null ? floor : residue).push(line);
349
+ if (opener !== null && opener[1] === fenceMarker) fenceMarker = null;
350
+ continue;
351
+ }
352
+
353
+ if (IRON_LAW_HEADING_RE.test(line)) {
354
+ ironLevel = heading?.[1]!.length ?? 2;
355
+ inPointerParagraph = false;
356
+ floor.push(line);
357
+ continue;
358
+ }
359
+
360
+ if (heading !== null && ironLevel !== null && heading[1]!.length <= ironLevel) {
361
+ ironLevel = null;
362
+ }
363
+ if (heading !== null) inPointerParagraph = false;
364
+
365
+ if (opener !== null) {
366
+ fenceMarker = opener[1]!;
367
+ inPointerParagraph = false;
368
+ (ironLevel !== null ? floor : residue).push(line);
369
+ continue;
370
+ }
371
+
372
+ POINTER_RE.lastIndex = 0;
373
+ const pointerHere = POINTER_RE.test(line);
374
+ POINTER_RE.lastIndex = 0;
375
+ if (pointerHere) inPointerParagraph = true;
376
+ else if (line.trim() === '') inPointerParagraph = false;
377
+
378
+ if (ironLevel !== null || NEGATION_RE.test(line) || inPointerParagraph) {
379
+ floor.push(line);
380
+ continue;
381
+ }
382
+ residue.push(line);
383
+ }
384
+
385
+ return { floor, residue };
386
+ }
387
+
388
+ /**
389
+ * Measure every rule carrying a migration pointer.
390
+ *
391
+ * `withSplit` controls the floor/residue tokenisation. The check path needs only
392
+ * the body total, so it skips three of the four encodes per rule — the split is
393
+ * a reporting concern and no verdict depends on it.
394
+ */
395
+ export function readRules(root: string, withSplit = false): { rows: RuleRow[]; scanned: number } {
396
+ const dir = path.join(root, RULES_REL);
397
+ let names: string[];
398
+ try {
399
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
400
+ } catch {
401
+ return { rows: [], scanned: 0 };
402
+ }
403
+
404
+ // The ledger gate's broad matcher is the denominator: a rule it sees and this
405
+ // gate cannot parse is a phrasing drift, reported rather than skipped. It is
406
+ // deliberately unbounded, so a rule it matches counts only when the migration
407
+ // ledger also holds a record for it — see the docblock.
408
+ const broad = new Set(migratedRules(root));
409
+ const rows: RuleRow[] = [];
410
+
411
+ for (const n of names) {
412
+ const stem = n.slice(0, -3);
413
+ const text = fs.readFileSync(path.join(dir, n), 'utf8');
414
+ const joined = text.replace(/\s*\n\s*/g, ' ');
415
+
416
+ const pointers: PointerRow[] = [];
417
+ POINTER_RE.lastIndex = 0;
418
+ for (const m of joined.matchAll(POINTER_RE)) {
419
+ const raw = (m[1] ?? m[2] ?? '').trim();
420
+ const { resolved, tried } = resolvePointer(root, raw);
421
+ pointers.push({ rule: stem, target: raw, resolved, tried });
422
+ }
423
+
424
+ const ledgered = isFile(root, path.join(LEDGER_DIR_REL, `${stem}.yml`));
425
+ if (pointers.length === 0 && !(broad.has(stem) && ledgered)) continue;
426
+
427
+ const { frontmatter, body, parsed } = splitFrontmatter(text);
428
+ const split = withSplit
429
+ ? splitFloorResidue(body)
430
+ : { floor: [] as string[], residue: [] as string[] };
431
+ rows.push({
432
+ rule: stem,
433
+ tokens: gpt_tokens(text).tokens,
434
+ body_tokens: gpt_tokens(body).tokens,
435
+ frontmatter_tokens: gpt_tokens(frontmatter).tokens,
436
+ floor_tokens: withSplit ? gpt_tokens(split.floor.join('\n')).tokens : 0,
437
+ residue_tokens: withSplit ? gpt_tokens(split.residue.join('\n')).tokens : 0,
438
+ pointers,
439
+ unparsed: pointers.length === 0,
440
+ malformed_frontmatter: !parsed,
441
+ });
442
+ }
443
+
444
+ return { rows, scanned: names.length };
445
+ }
446
+
447
+ interface Ceilings {
448
+ _comment: string;
449
+ measured_at_commit: string;
450
+ tokens_exact: boolean;
451
+ /** rule stem → exact-BPE token ceiling. */
452
+ ceilings: Record<string, number>;
453
+ history: { date: string; rule: string; from: number; to: number; reason: string }[];
454
+ }
455
+
456
+ function ceilingComment(): string {
457
+ return (
458
+ 'Per-rule stub ceilings (road-to-rule-stub-projection Phase 2). One exact-BPE token ' +
459
+ 'ceiling per rule carrying a migration pointer. SHRINK-ONLY: a rule may fall below its ' +
460
+ 'ceiling freely, and may not rise above it. Raising a ceiling to clear a failing check ' +
461
+ 'is the config-weakening move this repo blocks by construction — every raise needs a ' +
462
+ '`history` entry whose `reason` is a real sentence. The ceilings are a floor-plus-residue ' +
463
+ 'SNAPSHOT taken at adoption, not a target: a rule at its ceiling is not thereby correct, ' +
464
+ 'only not worse. Regenerate with `check_rule_stub_ceiling --write-baseline`; never ' +
465
+ 'hand-edit the counts.'
466
+ );
467
+ }
468
+
469
+ /**
470
+ * A raise reason must be a sentence, not the number restated.
471
+ *
472
+ * Mechanical, not editorial: a length floor, a denylist of known-empty
473
+ * phrasings, and a letters-remain test so "raised to 4200" and "4200" both fail
474
+ * while a real explanation passes. A reason can still be WRONG — that is a
475
+ * review problem, and no lint can be its judge.
476
+ */
477
+ export function isRealReason(reason: unknown): boolean {
478
+ if (typeof reason !== 'string') return false;
479
+ const trimmed = reason.trim();
480
+ // Denylist FIRST. Behind the length floor it was unreachable dead code —
481
+ // every entry is shorter than the floor, so `includes()` could never be the
482
+ // deciding check and the docblock advertised a mechanism that never ran.
483
+ // Ahead of it, the list does the job it was written for: rejecting a padded
484
+ // restatement such as "updated ................................".
485
+ if (EMPTY_REASONS.some((e) => trimmed.toLowerCase().startsWith(e))) return false;
486
+ if (trimmed.length < MIN_REASON_CHARS) return false;
487
+ return /[a-z]{4}/i.test(trimmed.replace(/\d/g, ''));
488
+ }
489
+
490
+ /**
491
+ * The LATEST raise per rule, which is the only one the committed ceiling can match.
492
+ *
493
+ * Checking every entry against the ceiling made the history array hold at most
494
+ * one entry per rule: a second raise turned the first, correct, historical row
495
+ * into a permanent red whose only cure was deleting the audit trail the array
496
+ * exists to hold. Later entries win; earlier ones are history, which is the point.
497
+ */
498
+ export function latestRaises(
499
+ history: readonly { rule?: unknown; from?: unknown; to?: unknown; reason?: unknown }[],
500
+ ): Map<string, { from: number; to: number; reason: unknown }> {
501
+ const out = new Map<string, { from: number; to: number; reason: unknown }>();
502
+ for (const h of history) {
503
+ if (typeof h.rule !== 'string') continue;
504
+ if (typeof h.from !== 'number' || typeof h.to !== 'number') continue;
505
+ if (!(h.to > h.from)) continue;
506
+ const seen = out.get(h.rule);
507
+ if (seen === undefined || h.to >= seen.to) out.set(h.rule, { from: h.from, to: h.to, reason: h.reason });
508
+ }
509
+ return out;
510
+ }
511
+
512
+ /**
513
+ * The commit the baseline was measured at.
514
+ *
515
+ * Read from git rather than left to an env var nobody documents: the sibling
516
+ * baseline records a real sha, and a ceiling disputed months later cannot be
517
+ * told apart from drift when its own file says `unrecorded`. The env var still
518
+ * wins where a caller sets it deliberately.
519
+ */
520
+ function headCommit(root: string): string | null {
521
+ const res = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
522
+ cwd: root,
523
+ encoding: 'utf-8',
524
+ });
525
+ const out = (res.stdout ?? '').trim();
526
+ return res.status === 0 && out !== '' ? out : null;
527
+ }
528
+
529
+ /** Markdown table for the Phase-0 evidence artifact. */
530
+ export function renderReport(rows: RuleRow[]): string {
531
+ const total = rows.reduce((a, r) => a + r.tokens, 0);
532
+ const fm = rows.reduce((a, r) => a + r.frontmatter_tokens, 0);
533
+ const body = rows.reduce((a, r) => a + r.body_tokens, 0);
534
+ const floor = rows.reduce((a, r) => a + r.floor_tokens, 0);
535
+ const residue = rows.reduce((a, r) => a + r.residue_tokens, 0);
536
+ const out: string[] = [];
537
+ out.push(`| rule | tokens | frontmatter | body (ceiling) | floor | residue | pointer target | resolves |`);
538
+ out.push(`|---|---:|---:|---:|---:|---:|---|---|`);
539
+ for (const r of rows.slice().sort((a, b) => b.residue_tokens - a.residue_tokens)) {
540
+ const targets = r.pointers.length === 0 ? '(unparsed)' : r.pointers.map((p) => `\`${p.target}\``).join(' · ');
541
+ const ok = r.pointers.length === 0 ? '—' : r.pointers.every((p) => p.resolved !== null) ? 'yes' : 'NO';
542
+ out.push(
543
+ `| \`${r.rule}\` | ${r.tokens} | ${r.frontmatter_tokens} | ${r.body_tokens} | ` +
544
+ `${r.floor_tokens} | ${r.residue_tokens} | ${targets} | ${ok} |`,
545
+ );
546
+ }
547
+ out.push(
548
+ `| **total (${rows.length} rules)** | **${total}** | **${fm}** | **${body}** | ` +
549
+ `**${floor}** | **${residue}** | | |`,
550
+ );
551
+ // BPE is not additive across a split: merges that span a segment boundary are
552
+ // lost when the segments are encoded separately. Stating the residual is the
553
+ // honest form — a reader who adds the columns WILL find it, and a table that
554
+ // silently does not reconcile is worse than one that says by how much.
555
+ const parts = fm + floor + residue;
556
+ out.push('');
557
+ out.push(
558
+ `Column residual: \`frontmatter + floor + residue\` = ${parts} against ` +
559
+ `${total} whole-file tokens (${parts - total >= 0 ? '+' : ''}${parts - total}, ` +
560
+ `${(Math.abs(parts - total) / Math.max(total, 1) * 100).toFixed(2)} %), and ` +
561
+ `\`frontmatter + body\` = ${fm + body}. BPE is not additive across segment ` +
562
+ 'boundaries — merges spanning a split are lost when the segments are encoded ' +
563
+ 'separately. **The ceilings are keyed on the `body (ceiling)` column**, not on ' +
564
+ 'the whole file: frontmatter is the routing surface, and a rule that gains a ' +
565
+ 'trigger must not trip a prose ceiling.',
566
+ );
567
+ return out.join('\n');
568
+ }
569
+
570
+ export function main(): number {
571
+ if (ARGV.includes('--self-test')) {
572
+ return selfTest(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'));
573
+ }
574
+ const root = process.cwd();
575
+
576
+ let reading: { rows: RuleRow[]; scanned: number };
577
+ try {
578
+ reading = readRules(root);
579
+ assertScanned({
580
+ gate: GATE,
581
+ scanned: reading.scanned,
582
+ units: 'rule file(s)',
583
+ roots: [RULES_REL],
584
+ });
585
+ } catch (err) {
586
+ if (err instanceof DeadScopeError) {
587
+ process.stderr.write(`❌ ${err.message}\n`);
588
+ return 1;
589
+ }
590
+ throw err;
591
+ }
592
+
593
+ const rows = reading.rows;
594
+
595
+ if (REPORT) {
596
+ process.stdout.write(`${renderReport(readRules(root, true).rows)}\n`);
597
+ return 0;
598
+ }
599
+
600
+ const baseline_path = path.join(root, BASELINE_REL);
601
+
602
+ if (WRITE) {
603
+ const prior: Ceilings | null = fs.existsSync(baseline_path)
604
+ ? (JSON.parse(fs.readFileSync(baseline_path, 'utf8')) as Ceilings)
605
+ : null;
606
+ const ceilings: Record<string, number> = {};
607
+ for (const r of rows.slice().sort((a, b) => a.rule.localeCompare(b.rule))) {
608
+ ceilings[r.rule] = r.body_tokens;
609
+ }
610
+
611
+ // THE WRITE PATH IS WHERE THE RAISE ASSERTION LIVES OR DIES.
612
+ //
613
+ // Recomputing every ceiling from the tree and carrying `history` forward
614
+ // verbatim would make assertion 3 decorative: a rule grows past its
615
+ // ceiling, CI reds, and the failure text plus the baseline comment both
616
+ // point the operator at THIS command, which would silently re-anchor the
617
+ // whole corpus upward with `history` still empty. Worse, regeneration is
618
+ // the mandated fix for a newly migrated rule, so the silent raise would
619
+ // ride along with ordinary correct work.
620
+ //
621
+ // So a raise no history entry covers is REFUSED here. Lowering, and
622
+ // adding a rule that has no ceiling yet, both pass untouched — those are
623
+ // the moves the ratchet exists to allow.
624
+ const covered = latestRaises(prior?.history ?? []);
625
+ const unstated: string[] = [];
626
+ for (const [rule, next_ceiling] of Object.entries(ceilings)) {
627
+ const before = prior?.ceilings?.[rule];
628
+ if (before === undefined || next_ceiling <= before) continue;
629
+ const entry = covered.get(rule);
630
+ if (entry === undefined || entry.to < next_ceiling || !isRealReason(entry.reason)) {
631
+ unstated.push(
632
+ `\`${rule}\`: ${before} → ${next_ceiling} (+${next_ceiling - before})`,
633
+ );
634
+ }
635
+ }
636
+ if (unstated.length > 0) {
637
+ process.stderr.write(
638
+ `❌ ${GATE}: refusing to write — ${unstated.length} ceiling(s) would RISE with ` +
639
+ 'no history entry covering them.\n\n',
640
+ );
641
+ for (const u of unstated) process.stderr.write(` • ${u}\n`);
642
+ process.stderr.write(
643
+ '\n A raise is a stated decision. Either move the prose to the pointer target\n' +
644
+ ' (never delete it — `preservation-guard` binds), or add a `history` entry\n' +
645
+ ` per rule: {date, rule, from, to, reason} with a reason of at least\n` +
646
+ ` ${MIN_REASON_CHARS} characters saying what landed, then re-run this command.\n`,
647
+ );
648
+ return 1;
649
+ }
650
+
651
+ const next: Ceilings = {
652
+ _comment: ceilingComment(),
653
+ measured_at_commit:
654
+ process.env['AGENT_CONFIG_BASELINE_COMMIT'] ?? headCommit(root) ?? 'unrecorded',
655
+ tokens_exact: TIKTOKEN_AVAILABLE,
656
+ ceilings,
657
+ history: prior?.history ?? [],
658
+ };
659
+ fs.writeFileSync(baseline_path, `${JSON.stringify(next, null, 4)}\n`);
660
+ process.stdout.write(
661
+ `✅ ${GATE}: baseline written — ${Object.keys(ceilings).length} migrated rule(s) ` +
662
+ `at ${next.measured_at_commit} ` +
663
+ `(${next.tokens_exact ? 'exact BPE' : 'character proxy'}).\n`,
664
+ );
665
+ return 0;
666
+ }
667
+
668
+ if (!fs.existsSync(baseline_path)) {
669
+ process.stderr.write(
670
+ `❌ ${GATE}: no baseline at ${BASELINE_REL}. Run with --write-baseline once, ` +
671
+ 'commit the file, and the ceilings are live from then on.\n',
672
+ );
673
+ return 1;
674
+ }
675
+
676
+ const base = JSON.parse(fs.readFileSync(baseline_path, 'utf8')) as Ceilings;
677
+ const failures: string[] = [];
678
+ const ledger = new GateLedger(GATE);
679
+ ledger.plan(rows.map((r) => r.rule));
680
+
681
+ // A method change would report a measurement delta as a payload delta.
682
+ // Say so instead of ratcheting — same posture as the census weight axis.
683
+ if (base.tokens_exact !== TIKTOKEN_AVAILABLE) {
684
+ // NOT `complete` — the run failed, and a ledger line reporting 44 clean
685
+ // targets on a failed run is the gate-green-while-blind shape the ledger
686
+ // exists to make visible.
687
+ for (const r of rows) ledger.fail(r.rule, 'measurement method changed; ceiling unresolved');
688
+ ledger.report();
689
+ process.stdout.write(`scanned: ${reading.scanned}\n`);
690
+ process.stderr.write(
691
+ `❌ ${GATE}: token measurement METHOD changed (baseline ` +
692
+ `${base.tokens_exact ? 'exact' : 'proxy'} → current ` +
693
+ `${TIKTOKEN_AVAILABLE ? 'exact' : 'proxy'}). The ceilings are UNRESOLVED, not ` +
694
+ 'red: re-anchor in the same environment rather than reading a method delta as ' +
695
+ 'a corpus delta.\n',
696
+ );
697
+ return 1;
698
+ }
699
+
700
+ // Every declared raise must carry a real reason, checked INDEPENDENTLY of
701
+ // whether any rule is currently over its ceiling.
702
+ //
703
+ // The first implementation consulted the reason only inside the
704
+ // `tokens > ceiling` branch, which made it unreachable in practice: a raise
705
+ // is precisely the act that puts the rule back UNDER its ceiling, so the
706
+ // branch that would have checked the reason never ran. The gate's own
707
+ // self-test caught it. The requirement belongs to the baseline's integrity,
708
+ // not to the rule's current size.
709
+ // EVERY entry must carry a real reason — an unstated raise is unstated
710
+ // whether or not a later one superseded it.
711
+ for (const h of base.history ?? []) {
712
+ if (typeof h.rule !== 'string' || !(h.to > h.from)) continue;
713
+ if (!isRealReason(h.reason)) {
714
+ failures.push(
715
+ `baseline history raises \`${h.rule}\` ${h.from} → ${h.to} with no real reason ` +
716
+ `(got ${JSON.stringify(h.reason ?? null)}). A raise is a stated decision: ` +
717
+ `write the sentence that says what landed and why the ceiling moves with it. ` +
718
+ `Raising a ceiling to clear a red is the config-weakening move this repo ` +
719
+ `blocks by construction.`,
720
+ );
721
+ }
722
+ }
723
+
724
+ // Only the LATEST raise per rule is compared against the committed ceiling.
725
+ // Comparing every entry capped the history at one row per rule: a second
726
+ // raise turned the first, correct, historical entry into a permanent red
727
+ // whose only cure was deleting the audit trail.
728
+ const raised = latestRaises(base.history ?? []);
729
+ for (const [rule, entry] of raised) {
730
+ const committed = base.ceilings[rule];
731
+ if (committed !== undefined && committed !== entry.to) {
732
+ failures.push(
733
+ `baseline history says \`${rule}\` was last raised to ${entry.to}, but its ` +
734
+ `committed ceiling is ${committed}. The history and the ceilings disagree, ` +
735
+ `which is the signature of a hand-edited count — regenerate with ` +
736
+ `--write-baseline instead of editing the number.`,
737
+ );
738
+ }
739
+ }
740
+
741
+ for (const r of rows) {
742
+ const reasons: string[] = [];
743
+
744
+ if (r.unparsed) {
745
+ reasons.push(
746
+ 'carries a migration line the ledger gate sees, but no target this gate can ' +
747
+ 'parse. A reworded pointer must surface as a finding, never as silence — ' +
748
+ 'either restore a parseable target or extend POINTER_RE with the new form',
749
+ );
750
+ }
751
+ for (const p of r.pointers) {
752
+ if (p.resolved === null) {
753
+ reasons.push(
754
+ `migration pointer target \`${p.target}\` resolves to nothing (tried: ` +
755
+ `${p.tried.length > 0 ? p.tried.join(', ') : 'no known shape'})`,
756
+ );
757
+ }
758
+ }
759
+
760
+ if (r.malformed_frontmatter) {
761
+ reasons.push(
762
+ 'its opening `---` never closes, so the body measured as empty. That is a ' +
763
+ 'malformed file rather than a small one, and it would otherwise contribute ' +
764
+ 'zero residue with no signal',
765
+ );
766
+ }
767
+
768
+ const ceiling = base.ceilings[r.rule];
769
+ if (ceiling === undefined) {
770
+ reasons.push(
771
+ `no ceiling in ${BASELINE_REL}. A newly migrated rule needs one: re-anchor with ` +
772
+ '--write-baseline in the same commit that adds the pointer',
773
+ );
774
+ } else if (r.body_tokens > ceiling) {
775
+ // The write path refuses an uncovered raise, so reaching here means
776
+ // the committed ceiling is below the tree. A covering history entry
777
+ // cannot rescue it: `latestRaises` already required entry.to to EQUAL
778
+ // the committed ceiling, so entry.to >= body_tokens > ceiling == entry.to
779
+ // is a contradiction. The overflow is simply real.
780
+ reasons.push(
781
+ `body grew past its ceiling (${ceiling} → ${r.body_tokens} tokens, ` +
782
+ `+${r.body_tokens - ceiling}). A migrated rule is held at the size its pointer ` +
783
+ 'claims. Move the prose to the pointer target — never delete it, ' +
784
+ '`preservation-guard` binds — or raise the ceiling with a `history` entry ' +
785
+ 'whose reason is a real sentence, which --write-baseline then accepts',
786
+ );
787
+ }
788
+
789
+ if (reasons.length > 0) {
790
+ ledger.fail(r.rule, reasons[0]!);
791
+ for (const reason of reasons) failures.push(`\`${r.rule}\`: ${reason}`);
792
+ } else {
793
+ ledger.complete(r.rule);
794
+ }
795
+ }
796
+
797
+ ledger.report();
798
+
799
+ // Exactly one `scanned: <N>` line, on BOTH paths and regardless of --quiet: a
800
+ // gate that goes blind must be detectable on the run where it says nothing
801
+ // else. Contract rule 1 of `src/config/gate-coverage.yml`. Emitted BEFORE the
802
+ // failure return — behind it, a real violation earned a second, misleading
803
+ // red from check_gate_coverage about blindness rather than about the finding.
804
+ process.stdout.write(`scanned: ${reading.scanned}\n`);
805
+
806
+ if (failures.length > 0) {
807
+ process.stderr.write(`❌ ${GATE}: a migrated rule stopped matching its own pointer.\n\n`);
808
+ for (const f of failures) process.stderr.write(` • ${f}\n`);
809
+ process.stderr.write(
810
+ '\n The pointer is a claim the rule makes about itself. This gate holds it —\n' +
811
+ ' the point is that a change is stated, not that it is forbidden.\n',
812
+ );
813
+ return 1;
814
+ }
815
+
816
+ if (!QUIET) {
817
+ // No residue figure here, deliberately. The check path skips the
818
+ // floor/residue encodes, so printing that sum would print a zero this run
819
+ // never computed — a number nothing measured is worse than no number.
820
+ // `--report` is where the split lives.
821
+ const body = rows.reduce((a, r) => a + r.body_tokens, 0);
822
+ process.stdout.write(
823
+ `✅ ${GATE}: ${rows.length} migrated rule(s) under ceiling · ` +
824
+ `${rows.reduce((a, r) => a + r.pointers.length, 0)} pointer(s) resolved · ` +
825
+ `${body} body tokens ` +
826
+ `(${TIKTOKEN_AVAILABLE ? 'exact BPE' : 'character proxy'}) · ` +
827
+ `${reading.scanned} rule file(s).\n`,
828
+ );
829
+ }
830
+ return 0;
831
+ }
832
+
833
+ /** A migrated rule whose pointer resolves, used as the accept fixture. */
834
+ const FIXTURE_TARGET_REL = path.join('docs', 'guidelines', 'alpha-mechanics.md');
835
+ const FIXTURE_OK = [
836
+ '---',
837
+ 'type: "auto"',
838
+ '---',
839
+ '',
840
+ '# Alpha',
841
+ '',
842
+ '## The Iron Law',
843
+ '',
844
+ '```',
845
+ 'NEVER DO THE WRONG THING.',
846
+ '```',
847
+ '',
848
+ 'Body migrated to `guideline:alpha-mechanics` (per P4 of the router roadmap).',
849
+ '',
850
+ ].join('\n');
851
+
852
+ interface FixtureShape {
853
+ /** Write the rule + its pointer target. Off for the dead-scope case. */
854
+ rules: boolean;
855
+ /** Write the pointer target file. Off for the unresolved-pointer case. */
856
+ target?: boolean;
857
+ /** Write a baseline at all. Off for the missing-baseline case. */
858
+ baseline: boolean;
859
+ /** Extra body appended to the rule AFTER the baseline is derived. */
860
+ pad?: string;
861
+ /** Replace the pointer line, to test the unparsed-phrasing path. */
862
+ rewordPointer?: string;
863
+ /** Mutate the derived baseline before writing. */
864
+ tamper?: (b: Ceilings) => void;
865
+ }
866
+
867
+ /**
868
+ * Build a throwaway tree the gate can run against.
869
+ *
870
+ * The baseline is DERIVED by the same `readRules` the gate uses, then the rule is
871
+ * optionally padded. Hand-writing an expected token total would make the accept
872
+ * case a test of my arithmetic rather than of the gate.
873
+ */
874
+ function buildFixture(dir: string, shape: FixtureShape): string {
875
+ fs.mkdirSync(path.join(dir, RULES_REL), { recursive: true });
876
+ fs.mkdirSync(path.join(dir, 'src', 'config'), { recursive: true });
877
+ fs.mkdirSync(path.join(dir, LEDGER_DIR_REL), { recursive: true });
878
+ fs.mkdirSync(path.join(dir, path.dirname(FIXTURE_TARGET_REL)), { recursive: true });
879
+
880
+ // The migration ledger bounds the broad matcher, so the fixture needs one for
881
+ // the reworded-pointer case to be a finding rather than a skip.
882
+ fs.writeFileSync(path.join(dir, LEDGER_DIR_REL, 'alpha.yml'), 'rule: alpha\n');
883
+
884
+ const rulePath = path.join(dir, RULES_REL, 'alpha.md');
885
+ if (shape.rules) {
886
+ let body = FIXTURE_OK;
887
+ if (shape.rewordPointer !== undefined) {
888
+ body = body.replace(
889
+ 'Body migrated to `guideline:alpha-mechanics` (per P4 of the router roadmap).',
890
+ shape.rewordPointer,
891
+ );
892
+ }
893
+ fs.writeFileSync(rulePath, body);
894
+ }
895
+ if (shape.target !== false) {
896
+ fs.writeFileSync(path.join(dir, FIXTURE_TARGET_REL), '# Alpha mechanics\n');
897
+ }
898
+
899
+ if (shape.baseline) {
900
+ const derived = shape.rules ? readRulesIn(dir) : [];
901
+ const ceilings: Record<string, number> = {};
902
+ for (const r of derived) ceilings[r.rule] = r.body_tokens;
903
+ const base: Ceilings = {
904
+ _comment: 'self-test fixture',
905
+ measured_at_commit: 'fixture',
906
+ tokens_exact: TIKTOKEN_AVAILABLE,
907
+ ceilings,
908
+ history: [],
909
+ };
910
+ shape.tamper?.(base);
911
+ fs.writeFileSync(path.join(dir, BASELINE_REL), `${JSON.stringify(base, null, 4)}\n`);
912
+ }
913
+
914
+ // Padding lands AFTER the baseline so the fixture grows against its own ceiling.
915
+ if (shape.rules && shape.pad !== undefined) {
916
+ fs.appendFileSync(rulePath, shape.pad);
917
+ }
918
+ return dir;
919
+ }
920
+
921
+ /** `readRules` against an arbitrary root — the fixture builder's measurement path. */
922
+ function readRulesIn(dir: string): RuleRow[] {
923
+ return readRules(dir, true).rows;
924
+ }
925
+
926
+ /**
927
+ * Prove the gate discriminates rather than merely exits.
928
+ *
929
+ * The rejecting cases are the ways this gate is worth having: a broken pointer
930
+ * target, a rule grown past its ceiling, a raise with no real reason, a history
931
+ * that disagrees with the committed ceiling, a reworded pointer this gate cannot
932
+ * parse, a `--write-baseline` that would raise silently, and the dead scan root.
933
+ * The dead-root case is the one a refactor is most likely to break silently — a
934
+ * gate that scans nothing and exits green is exactly what `assertScanned` exists
935
+ * for.
936
+ *
937
+ * The accepting cases are not filler. Three of them pin behaviour the ratchet
938
+ * must NOT punish: a stated raise, a second raise that keeps the first row, and
939
+ * a regeneration that only lowers. A ratchet with no accept cases drifts into
940
+ * refusing legitimate work, which is how a gate stops being read.
941
+ */
942
+ export function selfTest(repoRoot: string): number {
943
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rsc-selftest-'));
944
+ const run = (shape: FixtureShape, args: readonly string[] = ['--quiet']): number => {
945
+ const work = buildFixture(fs.mkdtempSync(path.join(root, 'case-')), shape);
946
+ return runGateCli(repoRoot, SCRIPT_REL, args, work);
947
+ };
948
+ const padding = `\n${'Filler prose that is neither an Iron Law nor a negation clause. '.repeat(40)}\n`;
949
+
950
+ const cases: SelfTestCase[] = [
951
+ {
952
+ name: 'a migrated rule at its ceiling with a resolving pointer passes',
953
+ expect: 'accept',
954
+ run: () => run({ rules: true, baseline: true }),
955
+ },
956
+ {
957
+ name: 'a pointer whose target does not resolve is REFUSED',
958
+ expect: 'reject',
959
+ run: () => run({ rules: true, baseline: true, target: false }),
960
+ },
961
+ {
962
+ name: 'a migrated rule padded past its ceiling is REFUSED',
963
+ expect: 'reject',
964
+ run: () => run({ rules: true, baseline: true, pad: padding }),
965
+ },
966
+ // The next two isolate the REASON requirement from the size check: no
967
+ // padding, so the rule sits comfortably under its raised ceiling and the
968
+ // only thing separating accept from reject is the sentence. That framing
969
+ // is the whole point — the first implementation checked the reason only
970
+ // when the rule was over its ceiling, which a raise by definition fixes,
971
+ // so the check was unreachable and this suite is what surfaced it.
972
+ {
973
+ name: 'a raise whose reason is empty is REFUSED even when nothing is over its ceiling',
974
+ expect: 'reject',
975
+ run: () =>
976
+ run({
977
+ rules: true,
978
+ baseline: true,
979
+ tamper: (b) => {
980
+ const at = b.ceilings['alpha'] ?? 0;
981
+ b.ceilings['alpha'] = at + 10_000;
982
+ b.history = [
983
+ { date: '2026-08-17', rule: 'alpha', from: at, to: at + 10_000, reason: 'raise' },
984
+ ];
985
+ },
986
+ }),
987
+ },
988
+ {
989
+ name: 'the same raise WITH a real reason sentence passes',
990
+ expect: 'accept',
991
+ run: () =>
992
+ run({
993
+ rules: true,
994
+ baseline: true,
995
+ tamper: (b) => {
996
+ const at = b.ceilings['alpha'] ?? 0;
997
+ b.ceilings['alpha'] = at + 10_000;
998
+ b.history = [
999
+ {
1000
+ date: '2026-08-17',
1001
+ rule: 'alpha',
1002
+ from: at,
1003
+ to: at + 10_000,
1004
+ reason:
1005
+ 'A measured finding landed in this rule and its worked example ' +
1006
+ 'is load-bearing, so the ceiling moves with the authored change.',
1007
+ },
1008
+ ];
1009
+ },
1010
+ }),
1011
+ },
1012
+ {
1013
+ name: 'a history entry disagreeing with the committed ceiling is REFUSED',
1014
+ expect: 'reject',
1015
+ run: () =>
1016
+ run({
1017
+ rules: true,
1018
+ baseline: true,
1019
+ tamper: (b) => {
1020
+ const at = b.ceilings['alpha'] ?? 0;
1021
+ b.history = [
1022
+ {
1023
+ date: '2026-08-17',
1024
+ rule: 'alpha',
1025
+ from: at,
1026
+ to: at + 5_000,
1027
+ reason:
1028
+ 'A real sentence, so the only thing wrong here is that the ' +
1029
+ 'committed ceiling was never regenerated to match the claim.',
1030
+ },
1031
+ ];
1032
+ },
1033
+ }),
1034
+ },
1035
+ {
1036
+ // Risk 3 of the roadmap: 44 pointers were hand-written over months in
1037
+ // several phrasings, so a gate keyed to one phrasing silently stops
1038
+ // seeing the others. This case pins the opposite behaviour — a rule the
1039
+ // ledger's broad matcher sees and this gate cannot parse is a FINDING.
1040
+ name: 'a reworded pointer this gate cannot parse is REFUSED, not silently skipped',
1041
+ expect: 'reject',
1042
+ run: () =>
1043
+ run({
1044
+ rules: true,
1045
+ baseline: true,
1046
+ rewordPointer: 'The body was migrated to the guideline on alpha mechanics.',
1047
+ }),
1048
+ },
1049
+ {
1050
+ // The HIGH finding of this change's own completion review. Without
1051
+ // this, assertion 3 is decorative: the failure text and the baseline
1052
+ // comment both send the operator to --write-baseline, which would
1053
+ // silently re-anchor the corpus upward.
1054
+ name: '--write-baseline REFUSES to raise a ceiling no history entry covers',
1055
+ expect: 'reject',
1056
+ run: () => run({ rules: true, baseline: true, pad: padding }, ['--write-baseline']),
1057
+ },
1058
+ {
1059
+ name: '--write-baseline accepts a LOWERING with no history entry at all',
1060
+ expect: 'accept',
1061
+ run: () =>
1062
+ run(
1063
+ {
1064
+ rules: true,
1065
+ baseline: true,
1066
+ tamper: (b) => {
1067
+ // Ceiling far above the tree: the regeneration lowers it.
1068
+ b.ceilings['alpha'] = (b.ceilings['alpha'] ?? 0) + 5_000;
1069
+ },
1070
+ },
1071
+ ['--write-baseline'],
1072
+ ),
1073
+ },
1074
+ {
1075
+ // A second raise used to red the gate permanently, because the first,
1076
+ // correct, historical entry disagreed with the committed ceiling.
1077
+ name: 'a SECOND raise for the same rule leaves the earlier entry intact and passes',
1078
+ expect: 'accept',
1079
+ run: () =>
1080
+ run({
1081
+ rules: true,
1082
+ baseline: true,
1083
+ tamper: (b) => {
1084
+ const at = b.ceilings['alpha'] ?? 0;
1085
+ b.ceilings['alpha'] = at + 2_000;
1086
+ b.history = [
1087
+ {
1088
+ date: '2026-08-17',
1089
+ rule: 'alpha',
1090
+ from: at,
1091
+ to: at + 1_000,
1092
+ reason:
1093
+ 'First raise: a measured finding landed and its worked example ' +
1094
+ 'is load-bearing, so the ceiling moved with the authored change.',
1095
+ },
1096
+ {
1097
+ date: '2026-08-18',
1098
+ rule: 'alpha',
1099
+ from: at + 1_000,
1100
+ to: at + 2_000,
1101
+ reason:
1102
+ 'Second raise: a further finding landed in the same rule, and ' +
1103
+ 'the audit trail keeps both rows rather than overwriting one.',
1104
+ },
1105
+ ];
1106
+ },
1107
+ }),
1108
+ },
1109
+ {
1110
+ name: 'a dead scan root is REFUSED rather than reported clean',
1111
+ expect: 'reject',
1112
+ run: () => run({ rules: false, baseline: true }),
1113
+ },
1114
+ ];
1115
+
1116
+ return runSelfTest({
1117
+ gate: GATE,
1118
+ cases,
1119
+ minCases: SELF_TEST_MIN_CASES,
1120
+ minRejectCases: SELF_TEST_MIN_REJECT,
1121
+ });
1122
+ }
1123
+
1124
+ if (process.argv[1] && import.meta.url === pathToFileURL(fileURLToPath(import.meta.url)).href) {
1125
+ const invoked = path.resolve(process.argv[1]);
1126
+ const self = path.resolve(fileURLToPath(import.meta.url));
1127
+ if (invoked === self) process.exit(main());
1128
+ }