@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +195 -315
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
- package/dist/agent-src/rules/ui-audit-gate.md +25 -8
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
- package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +336 -112
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +40 -18
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +2128 -1326
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1764 -899
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +111 -5
- package/src/config/gate-violation-baselines.json +5 -5
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +20 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +80 -143
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +492 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/release.ts +38 -53
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- 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
|
+
}
|