@event4u/agent-config 13.0.0 → 14.0.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 (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +160 -333
  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 +224 -104
  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 +2018 -1320
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1652 -892
  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 +2 -2
  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/gh_transient.ts +80 -0
  75. package/src/scripts/_lib/release_material.ts +16 -5
  76. package/src/scripts/_lib/review_independence.ts +111 -0
  77. package/src/scripts/_lib/session_register.ts +14 -0
  78. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  79. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  80. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  81. package/src/scripts/ai_council/_py_json.ts +96 -0
  82. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  83. package/src/scripts/ai_council/clients.ts +80 -143
  84. package/src/scripts/ai_council/config.ts +25 -11
  85. package/src/scripts/ai_council/probe_store.ts +196 -0
  86. package/src/scripts/ai_council/qualification.ts +425 -0
  87. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  88. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  89. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  90. package/src/scripts/capture_skill_catalogue.ts +264 -4
  91. package/src/scripts/check_evaluator_schema.ts +249 -0
  92. package/src/scripts/check_pr_ci_current.ts +409 -0
  93. package/src/scripts/check_review_schema.ts +195 -0
  94. package/src/scripts/check_rule_activation_census.ts +451 -0
  95. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  96. package/src/scripts/condense.ts +1 -1
  97. package/src/scripts/council_cli.ts +141 -191
  98. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  99. package/src/scripts/hook_manifest.yaml +72 -5
  100. package/src/scripts/hooks/concern_registry.ts +4 -0
  101. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  102. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  103. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  105. package/src/scripts/hooks_doctor.ts +112 -0
  106. package/src/scripts/interruption_report.ts +443 -0
  107. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  108. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  109. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  110. package/src/scripts/release.ts +38 -53
  111. package/src/scripts/rule_activation_census.ts +292 -0
  112. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  113. package/src/scripts/schemas/review-findings.schema.json +31 -0
  114. package/src/scripts/self_review_gate.ts +6 -0
  115. package/src/scripts/session_register_hook.ts +30 -1
  116. package/src/scripts/sync_pr_branch.ts +271 -0
  117. package/src/scripts/test_council_qualification.ts +213 -0
  118. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hard-gate: the rule-activation split may not move unwitnessed.
4
+ *
5
+ * `road-to-mixed-trigger-activation-cost` step 4.1, and the reason it exists is
6
+ * the roadmap's own case study. Commit `33c7c20` moved **nineteen** rules from
7
+ * path-scoped to always-on in one emitter change. The change was correct — it
8
+ * stopped scoping from silently deleting keyword reach — and it recorded its own
9
+ * cost in the commit message. But nothing in the repository would have caught it
10
+ * if the message had said nothing: no gate watched the split, so the only
11
+ * witnesses were a commit body and, months later, an external analysis. A
12
+ * nineteen-rule activation change that reaches every consumer session should not
13
+ * depend on an author remembering to write it down.
14
+ *
15
+ * The gate is a RATCHET on two axes, both in the same direction the estate's
16
+ * other payload budgets use (`preamble-payload-budget`, `hook-token-budget`):
17
+ *
18
+ * 1. **Identity** — the scoped and mixed ID SETS are pinned. Any rule entering
19
+ * or leaving either set fails the gate and is NAMED. This is the axis that
20
+ * would have caught `33c7c20`: a count-only check can be satisfied by a
21
+ * coincidence (one rule in, one rule out), an id-set check cannot.
22
+ * 2. **Weight** — the unconditional corpus's token total may only walk DOWN.
23
+ * Growth fails. Raising the baseline is a deliberate PR with a stated
24
+ * reason, never a silent number edit — raising a ratchet baseline to clear a
25
+ * failing check is the config-weakening move this repo blocks by
26
+ * construction, and the same sentence is written into the baseline file.
27
+ *
28
+ * Measurement is exact-BPE where `js-tiktoken` resolves and the documented
29
+ * character proxy where it does not; the gate says which it used rather than
30
+ * presenting a proxy as a measurement. The roadmap's own § 1 claim 11 is
31
+ * unverifiable precisely because the figure it inherited was a proxy quoted as a
32
+ * measurement, so this gate refuses to repeat that.
33
+ *
34
+ * The verdict per rule is not computed here. It comes from
35
+ * `rule_activation_census.ts`, which reads the emitter's own exported
36
+ * `_claude_paths_plan`. Measurement and enforcement stay in separate files: the
37
+ * census has no failure exit and this gate has no opinion of its own.
38
+ *
39
+ * CLI contract: exit 0 = at or under the ratchet, 1 = moved.
40
+ * `--quiet` suppresses the green line. `--write-baseline` re-anchors the file
41
+ * from the current tree and is the only way the numbers change.
42
+ */
43
+ import * as fs from 'node:fs';
44
+ import * as os from 'node:os';
45
+ import * as path from 'node:path';
46
+ import { fileURLToPath, pathToFileURL } from 'node:url';
47
+
48
+ import { GateLedger } from './_lib/gate_ledger.js';
49
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
50
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
51
+ import { gpt_tokens, TIKTOKEN_AVAILABLE } from './_lib/token_count.js';
52
+ import { census, summarize } from './rule_activation_census.js';
53
+
54
+ const SCRIPT_REL = path.join('src', 'scripts', 'check_rule_activation_census.ts');
55
+
56
+ /**
57
+ * Floors for the self-test suite, set below the shipped case count on purpose.
58
+ *
59
+ * Five cases ship and four of them reject. The floors sit at 4/3 so retiring one
60
+ * case that has genuinely stopped describing the gate does not red the suite,
61
+ * while deleting the suite down to a single happy path — the cheapest route to a
62
+ * green self-test — still fails.
63
+ */
64
+ const SELF_TEST_MIN_CASES = 4;
65
+ const SELF_TEST_MIN_REJECT = 3;
66
+
67
+ const QUIET = process.argv.slice(2).includes('--quiet');
68
+ const WRITE = process.argv.slice(2).includes('--write-baseline');
69
+ const GATE = 'check_rule_activation_census';
70
+ const BASELINE_REL = path.join('src', 'config', 'rule-activation-census.json');
71
+ const RULES_REL = path.join('src', 'rules');
72
+
73
+ /**
74
+ * How far the unconditional token total may drift upward before the gate is red.
75
+ *
76
+ * Not zero, deliberately: prose edits to an already-unconditional rule move this
77
+ * number by a few dozen tokens and are not the failure class the gate watches.
78
+ * The identity axis above has no such band — a rule changing activation class is
79
+ * never noise. The number is a STATED DEFAULT, not a measured optimum; it is the
80
+ * smallest band that does not fire on ordinary copy-editing in this corpus.
81
+ * Revisit-if: a run reports a red on the weight axis whose diff is purely
82
+ * editorial, which falsifies the band rather than the obligation.
83
+ */
84
+ const TOKEN_DRIFT_ALLOWANCE = 2_000;
85
+
86
+ interface Baseline {
87
+ _comment: string;
88
+ measured_at_commit: string;
89
+ tokens_exact: boolean;
90
+ unconditional_tokens: number;
91
+ scoped_ids: string[];
92
+ mixed_ids: string[];
93
+ baseline_history: { date: string; reason: string }[];
94
+ }
95
+
96
+ export interface CensusReading {
97
+ scoped_ids: string[];
98
+ mixed_ids: string[];
99
+ unconditional_tokens: number;
100
+ tokens_exact: boolean;
101
+ scanned: number;
102
+ }
103
+
104
+ /** Measure the current tree: activation sets plus the unconditional corpus weight. */
105
+ export function read_census(root: string): CensusReading {
106
+ const rows = census(root);
107
+ const sum = summarize(rows);
108
+ let tokens = 0;
109
+ for (const r of rows) {
110
+ if (r.verdict === 'scoped') continue; // scoped rules are not standing payload
111
+ const p = path.join(root, RULES_REL, `${r.id}.md`);
112
+ tokens += gpt_tokens(fs.readFileSync(p, 'utf8')).tokens;
113
+ }
114
+ return {
115
+ scoped_ids: sum.scoped_ids.slice().sort(),
116
+ mixed_ids: sum.mixed_ids.slice().sort(),
117
+ unconditional_tokens: tokens,
118
+ tokens_exact: TIKTOKEN_AVAILABLE,
119
+ scanned: rows.length,
120
+ };
121
+ }
122
+
123
+ function _diff(pinned: string[], current: string[]): { added: string[]; removed: string[] } {
124
+ const a = new Set(pinned);
125
+ const b = new Set(current);
126
+ return {
127
+ added: current.filter((x) => !a.has(x)),
128
+ removed: pinned.filter((x) => !b.has(x)),
129
+ };
130
+ }
131
+
132
+ /** A path-only rule: the emitter gives it `paths:`, so it reads as `scoped`. */
133
+ const FIXTURE_PATH_ONLY = [
134
+ '---',
135
+ 'type: "auto"',
136
+ 'triggers:',
137
+ ' - path_prefix: "components/"',
138
+ '---',
139
+ '',
140
+ '# Alpha',
141
+ '',
142
+ 'A rule whose obligation binds at file contact.',
143
+ '',
144
+ ].join('\n');
145
+
146
+ /** The same rule plus one keyword: the emitter drops `paths:` entirely. */
147
+ const FIXTURE_MIXED = [
148
+ '---',
149
+ 'type: "auto"',
150
+ 'triggers:',
151
+ ' - path_prefix: "components/"',
152
+ ' - keyword: "widget"',
153
+ '---',
154
+ '',
155
+ '# Beta',
156
+ '',
157
+ 'A rule carrying both a path and a prompt trigger.',
158
+ '',
159
+ ].join('\n');
160
+
161
+ interface FixtureShape {
162
+ /** Write the two rule files. Off for the dead-scope case. */
163
+ rules: boolean;
164
+ /** Write a baseline at all. Off for the missing-baseline case. */
165
+ baseline: boolean;
166
+ /** Mutate the generated baseline before writing, to move one identity axis. */
167
+ tamper?: (b: Record<string, unknown>) => void;
168
+ }
169
+
170
+ /**
171
+ * Build a throwaway tree the gate can run against.
172
+ *
173
+ * The baseline is DERIVED from the fixture by the same `read_census` the gate
174
+ * uses, then optionally tampered with. Hand-writing the expected token total
175
+ * would make the accept case a test of my arithmetic rather than of the gate.
176
+ */
177
+ function buildFixture(dir: string, shape: FixtureShape): string {
178
+ fs.mkdirSync(path.join(dir, RULES_REL), { recursive: true });
179
+ fs.mkdirSync(path.join(dir, 'src', 'config'), { recursive: true });
180
+ if (shape.rules) {
181
+ fs.writeFileSync(path.join(dir, RULES_REL, 'alpha-path-only.md'), FIXTURE_PATH_ONLY);
182
+ fs.writeFileSync(path.join(dir, RULES_REL, 'beta-mixed.md'), FIXTURE_MIXED);
183
+ }
184
+ if (shape.baseline) {
185
+ const reading = shape.rules
186
+ ? read_census(dir)
187
+ : {
188
+ scoped_ids: [],
189
+ mixed_ids: [],
190
+ unconditional_tokens: 0,
191
+ tokens_exact: TIKTOKEN_AVAILABLE,
192
+ scanned: 0,
193
+ };
194
+ const base: Record<string, unknown> = {
195
+ _comment: 'self-test fixture',
196
+ measured_at_commit: 'fixture',
197
+ tokens_exact: reading.tokens_exact,
198
+ unconditional_tokens: reading.unconditional_tokens,
199
+ scoped_ids: reading.scoped_ids,
200
+ mixed_ids: reading.mixed_ids,
201
+ baseline_history: [],
202
+ };
203
+ shape.tamper?.(base);
204
+ fs.writeFileSync(
205
+ path.join(dir, BASELINE_REL),
206
+ `${JSON.stringify(base, null, 4)}\n`,
207
+ );
208
+ }
209
+ return dir;
210
+ }
211
+
212
+ /**
213
+ * Prove the gate discriminates rather than merely exits.
214
+ *
215
+ * The four rejecting cases are the four ways this gate is worth having: each
216
+ * identity axis separately, the missing baseline, and the dead scan root. The
217
+ * dead-root case is the one a future refactor is most likely to break silently —
218
+ * a gate that scans nothing and exits green is the exact failure
219
+ * `assertScanned` exists for, and it is the reason that case is here rather than
220
+ * only in the manifest.
221
+ */
222
+ export function selfTest(repoRoot: string): number {
223
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rac-selftest-'));
224
+ const run = (shape: FixtureShape): number => {
225
+ const work = buildFixture(fs.mkdtempSync(path.join(root, 'case-')), shape);
226
+ return runGateCli(repoRoot, SCRIPT_REL, ['--quiet'], work);
227
+ };
228
+ const cases: SelfTestCase[] = [
229
+ {
230
+ name: 'a tree matching its baseline on both axes passes',
231
+ expect: 'accept',
232
+ run: () => run({ rules: true, baseline: true }),
233
+ },
234
+ {
235
+ name: 'a rule that left the scoped set is REFUSED',
236
+ expect: 'reject',
237
+ run: () =>
238
+ run({
239
+ rules: true,
240
+ baseline: true,
241
+ tamper: (b) => {
242
+ b['scoped_ids'] = ['beta-mixed'];
243
+ },
244
+ }),
245
+ },
246
+ {
247
+ name: 'a rule that left the mixed set is REFUSED',
248
+ expect: 'reject',
249
+ run: () =>
250
+ run({
251
+ rules: true,
252
+ baseline: true,
253
+ tamper: (b) => {
254
+ b['mixed_ids'] = [];
255
+ },
256
+ }),
257
+ },
258
+ {
259
+ name: 'no committed baseline is REFUSED, not treated as nothing to compare',
260
+ expect: 'reject',
261
+ run: () => run({ rules: true, baseline: false }),
262
+ },
263
+ {
264
+ name: 'an empty rule root is REFUSED — a gate that scanned nothing has not passed',
265
+ expect: 'reject',
266
+ run: () => run({ rules: false, baseline: true }),
267
+ },
268
+ ];
269
+ try {
270
+ return runSelfTest({
271
+ gate: GATE,
272
+ cases,
273
+ minCases: SELF_TEST_MIN_CASES,
274
+ minRejectCases: SELF_TEST_MIN_REJECT,
275
+ });
276
+ } finally {
277
+ fs.rmSync(root, { recursive: true, force: true });
278
+ }
279
+ }
280
+
281
+ export function main(): number {
282
+ if (process.argv.slice(2).includes('--self-test')) {
283
+ return selfTest(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'));
284
+ }
285
+ const root = process.cwd();
286
+ let reading: CensusReading;
287
+ try {
288
+ reading = read_census(root);
289
+ // A gate that read nothing has not passed. The rule corpus is the scanned
290
+ // unit; an empty one means the root moved, not that the estate is clean.
291
+ assertScanned({
292
+ gate: GATE,
293
+ scanned: reading.scanned,
294
+ units: 'rule file(s)',
295
+ roots: [RULES_REL],
296
+ });
297
+ } catch (err) {
298
+ if (err instanceof DeadScopeError) {
299
+ process.stderr.write(`❌ ${err.message}\n`);
300
+ return 1;
301
+ }
302
+ throw err;
303
+ }
304
+
305
+ const baseline_path = path.join(root, BASELINE_REL);
306
+
307
+ if (WRITE) {
308
+ const prior: Baseline | null = fs.existsSync(baseline_path)
309
+ ? (JSON.parse(fs.readFileSync(baseline_path, 'utf8')) as Baseline)
310
+ : null;
311
+ const next: Baseline = {
312
+ _comment:
313
+ 'Rule-activation ratchet baseline (road-to-mixed-trigger-activation-cost step 4.1). ' +
314
+ 'Two axes: the scoped/mixed ID SETS are pinned by identity, and the unconditional ' +
315
+ 'token total may only walk DOWN. Raising a number here to clear a failing check is ' +
316
+ 'the config-weakening move this repo blocks by construction — every raise needs a ' +
317
+ 'baseline_history entry whose reason is a real sentence. Regenerate with ' +
318
+ '`check_rule_activation_census --write-baseline`; never hand-edit the counts.',
319
+ measured_at_commit: process.env['AGENT_CONFIG_BASELINE_COMMIT'] ?? 'unrecorded',
320
+ tokens_exact: reading.tokens_exact,
321
+ unconditional_tokens: reading.unconditional_tokens,
322
+ scoped_ids: reading.scoped_ids,
323
+ mixed_ids: reading.mixed_ids,
324
+ baseline_history: prior?.baseline_history ?? [],
325
+ };
326
+ fs.writeFileSync(baseline_path, `${JSON.stringify(next, null, 4)}\n`);
327
+ process.stdout.write(
328
+ `✅ ${GATE}: baseline written — ${next.scoped_ids.length} scoped, ` +
329
+ `${next.mixed_ids.length} mixed, ${next.unconditional_tokens} unconditional tokens ` +
330
+ `(${next.tokens_exact ? 'exact BPE' : 'character proxy'}).\n`,
331
+ );
332
+ return 0;
333
+ }
334
+
335
+ if (!fs.existsSync(baseline_path)) {
336
+ process.stderr.write(
337
+ `❌ ${GATE}: no baseline at ${BASELINE_REL}. Run with --write-baseline once, ` +
338
+ 'commit the file, and the ratchet is live from then on.\n',
339
+ );
340
+ return 1;
341
+ }
342
+
343
+ const base = JSON.parse(fs.readFileSync(baseline_path, 'utf8')) as Baseline;
344
+ const failures: string[] = [];
345
+
346
+ // Per-target accounting. The gate's verdict is corpus-level — two set
347
+ // comparisons and one total — so the ledger's job here is to prove every rule
348
+ // file was actually reached, and to attribute a red to the specific rules that
349
+ // moved rather than to the corpus as a whole. A rule that moved is failed by
350
+ // name; every other rule is completed.
351
+ const ledger = new GateLedger(GATE);
352
+ const all_ids = census(root).map((r) => r.id);
353
+ ledger.plan(all_ids);
354
+
355
+ const scoped = _diff(base.scoped_ids, reading.scoped_ids);
356
+ if (scoped.added.length || scoped.removed.length) {
357
+ failures.push(
358
+ `scoped set moved (${base.scoped_ids.length} → ${reading.scoped_ids.length}):` +
359
+ (scoped.added.length ? `\n + now scoped: ${scoped.added.join(', ')}` : '') +
360
+ (scoped.removed.length
361
+ ? `\n − no longer scoped: ${scoped.removed.join(', ')}`
362
+ : ''),
363
+ );
364
+ }
365
+
366
+ const mixed = _diff(base.mixed_ids, reading.mixed_ids);
367
+ if (mixed.added.length || mixed.removed.length) {
368
+ failures.push(
369
+ `mixed set moved (${base.mixed_ids.length} → ${reading.mixed_ids.length}):` +
370
+ (mixed.added.length ? `\n + now mixed: ${mixed.added.join(', ')}` : '') +
371
+ (mixed.removed.length
372
+ ? `\n − no longer mixed: ${mixed.removed.join(', ')}`
373
+ : ''),
374
+ );
375
+ }
376
+
377
+ // Comparing an exact reading against a proxy baseline (or the reverse) would
378
+ // report a method change as a payload change. Say so instead of ratcheting.
379
+ if (base.tokens_exact !== reading.tokens_exact) {
380
+ failures.push(
381
+ `token measurement METHOD changed (baseline ` +
382
+ `${base.tokens_exact ? 'exact' : 'proxy'} → current ` +
383
+ `${reading.tokens_exact ? 'exact' : 'proxy'}). The weight axis is ` +
384
+ 'UNRESOLVED, not red: re-anchor the baseline in the same environment ' +
385
+ 'rather than reading a method delta as a corpus delta.',
386
+ );
387
+ } else {
388
+ const delta = reading.unconditional_tokens - base.unconditional_tokens;
389
+ if (delta > TOKEN_DRIFT_ALLOWANCE) {
390
+ failures.push(
391
+ `unconditional corpus grew by ${delta} tokens ` +
392
+ `(${base.unconditional_tokens} → ${reading.unconditional_tokens}), over the ` +
393
+ `${TOKEN_DRIFT_ALLOWANCE}-token drift allowance. Shrink it, or re-anchor with ` +
394
+ '--write-baseline plus a baseline_history reason.',
395
+ );
396
+ }
397
+ }
398
+
399
+ // Attribute the verdict per rule before reporting, so the ledger names the
400
+ // movers rather than reddening 117 targets for a two-rule change.
401
+ const moved = new Set<string>([
402
+ ...scoped.added,
403
+ ...scoped.removed,
404
+ ...mixed.added,
405
+ ...mixed.removed,
406
+ ]);
407
+ for (const id of all_ids) {
408
+ if (moved.has(id)) ledger.fail(id, 'activation class moved against the pinned baseline');
409
+ else ledger.complete(id);
410
+ }
411
+ // A baseline id absent from the corpus means the rule FILE was deleted, not
412
+ // that its activation moved. It is a legitimate change and it is already named
413
+ // in the set-diff text above — but it cannot be ledgered, because the ledger
414
+ // accounts for targets that exist and `plan()` never saw it. Failing it here
415
+ // would raise an unplanned-target error and mask the real diff.
416
+ ledger.report();
417
+
418
+ if (failures.length > 0) {
419
+ process.stderr.write(`❌ ${GATE}: the rule-activation split moved.\n\n`);
420
+ for (const f of failures) process.stderr.write(` • ${f}\n`);
421
+ process.stderr.write(
422
+ '\n Every rule named above changed how often it loads in a consumer session.\n' +
423
+ ' If that is intended, re-anchor the baseline in the SAME commit and record\n' +
424
+ ' the reason — the point of this gate is that the change is stated, not that\n' +
425
+ ' it is forbidden.\n',
426
+ );
427
+ return 1;
428
+ }
429
+
430
+ // Machine-readable count for the gate-coverage manifest, emitted on BOTH
431
+ // paths and regardless of --quiet: a gate that goes blind must be detectable
432
+ // even on the run where it reports nothing else. Contract rule 1 of
433
+ // `src/config/gate-coverage.yml` — exactly one `scanned: <N>` line.
434
+ process.stdout.write(`scanned: ${reading.scanned}\n`);
435
+
436
+ if (!QUIET) {
437
+ process.stdout.write(
438
+ `✅ ${GATE}: ${reading.scoped_ids.length} scoped · ` +
439
+ `${reading.mixed_ids.length} mixed · ${reading.unconditional_tokens} unconditional ` +
440
+ `tokens (${reading.tokens_exact ? 'exact BPE' : 'character proxy'}) · ` +
441
+ `${reading.scanned} rule file(s).\n`,
442
+ );
443
+ }
444
+ return 0;
445
+ }
446
+
447
+ if (process.argv[1] && import.meta.url === pathToFileURL(fileURLToPath(import.meta.url)).href) {
448
+ const invoked = path.resolve(process.argv[1]);
449
+ const self = path.resolve(fileURLToPath(import.meta.url));
450
+ if (invoked === self) process.exit(main());
451
+ }