@event4u/agent-config 14.22.0 → 14.23.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 (111) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +159 -380
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +47 -38
  7. package/dist/agent-src/commands/roadmap/next.md +8 -0
  8. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
  9. package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
  10. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
  11. package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -1
  12. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
  13. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +1 -1
  16. package/dist/cli-delegate/archive_completed_roadmaps.js +52 -4
  17. package/dist/cli-delegate/{chunk-TGIPZBCW.js → chunk-2KKXJW25.js} +22 -8
  18. package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
  19. package/dist/cli-delegate/{chunk-BIXEDQPZ.js → chunk-KCNTUSPI.js} +1 -1
  20. package/dist/cli-delegate/cmd_export.js +2 -2
  21. package/dist/cli-delegate/cmd_handoff.js +20 -12
  22. package/dist/cli-delegate/cmd_prune.js +2 -2
  23. package/dist/cli-delegate/cmd_session_recycle.js +3 -13
  24. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  25. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  26. package/dist/cli-delegate/cmd_sync.js +2 -2
  27. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  28. package/dist/cli-delegate/cmd_validate.js +2 -2
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +8 -8
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +1 -1
  33. package/dist/discovery/dormancy-report.md +1 -1
  34. package/dist/discovery/orphan-report.md +1 -1
  35. package/dist/discovery/packs.json +2 -2
  36. package/dist/discovery/trust-report.md +1 -1
  37. package/dist/discovery/workspaces.json +2 -2
  38. package/dist/hooks/dispatch.js +880 -289
  39. package/dist/install/install.mjs +15 -3
  40. package/dist/mcp/registry-manifest.json +2 -2
  41. package/dist/mcp/server.json +2 -2
  42. package/dist/mcp/server.mjs +5458 -2453
  43. package/dist/scripts/_lib/lean_projection_mode.js +108 -0
  44. package/dist/scripts/_lib/router_match.js +243 -0
  45. package/dist/scripts/_lib/rule_injection.js +216 -0
  46. package/dist/scripts/condense.js +20 -20
  47. package/dist/scripts/project_thin_rules.js +139 -19
  48. package/dist/server/schemas/settings.js +16 -3
  49. package/docs/MIGRATION.md +56 -0
  50. package/docs/contracts/settings-classes.md +39 -7
  51. package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
  52. package/llms.txt +1 -1
  53. package/package.json +1 -1
  54. package/src/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
  55. package/src/config/agent-settings.template.yml +47 -1
  56. package/src/config/continuity-surface.json +7 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-violation-baselines.json +2 -2
  59. package/src/config/hook-token-budget.json +7 -6
  60. package/src/config/packed-binary-manifest.json +1 -1
  61. package/src/config/routing-coverage-seed.json +9 -2
  62. package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
  63. package/src/scripts/_cli/handoff_sessions.ts +14 -44
  64. package/src/scripts/_lib/continuity_slot.ts +295 -0
  65. package/src/scripts/_lib/continuity_writer.ts +253 -0
  66. package/src/scripts/_lib/hook_settings.ts +85 -0
  67. package/src/scripts/_lib/lean_projection_mode.ts +127 -0
  68. package/src/scripts/_lib/packed_binary_predicate.ts +37 -0
  69. package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
  70. package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
  71. package/src/scripts/_lib/session_eol.ts +44 -0
  72. package/src/scripts/_lib/session_index_trust.ts +346 -0
  73. package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
  74. package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
  75. package/src/scripts/audit_mcp_tools.ts +69 -25
  76. package/src/scripts/build_mcp_catalog.ts +24 -1
  77. package/src/scripts/check_host_tree_parity.ts +291 -0
  78. package/src/scripts/check_preamble_payload_budget.ts +136 -7
  79. package/src/scripts/check_rule_projection_integrity.ts +102 -2
  80. package/src/scripts/ci_settle.ts +205 -5
  81. package/src/scripts/code_graph/build.ts +79 -0
  82. package/src/scripts/code_graph/cli.ts +258 -7
  83. package/src/scripts/code_graph/detect.ts +38 -0
  84. package/src/scripts/code_graph/query.ts +25 -2
  85. package/src/scripts/code_graph/sqlite_store.ts +16 -1
  86. package/src/scripts/code_graph/types.ts +52 -3
  87. package/src/scripts/code_graph/validate.ts +3 -1
  88. package/src/scripts/code_graph/verbs.ts +590 -0
  89. package/src/scripts/condense.ts +20 -20
  90. package/src/scripts/handoff_context_hook.ts +39 -1
  91. package/src/scripts/hook_manifest.json +1 -1
  92. package/src/scripts/hook_manifest.yaml +24 -1
  93. package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
  94. package/src/scripts/hooks/code_graph_context_hook.ts +7 -18
  95. package/src/scripts/hooks/push_settle_hook.ts +3 -2
  96. package/src/scripts/hooks/rule_inject_hook.ts +107 -20
  97. package/src/scripts/hooks/session_eol_hook.ts +120 -2
  98. package/src/scripts/hot_context_hook.ts +62 -7
  99. package/src/scripts/lint_roadmap_blockers.ts +164 -1
  100. package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
  101. package/src/scripts/mcp_server/graph_tools.ts +294 -0
  102. package/src/scripts/mcp_server/path_util.ts +31 -0
  103. package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
  104. package/src/scripts/mcp_server/tools.ts +3 -13
  105. package/src/scripts/model_rule_injection.ts +61 -4
  106. package/src/scripts/project_thin_rules.ts +149 -16
  107. package/src/scripts/report_standing_payload_by_host.ts +650 -0
  108. package/src/scripts/schemas/agent-settings.schema.json +7 -1
  109. package/src/scripts/session_memory_index.ts +81 -8
  110. package/src/server/schemas/settings.ts +23 -4
  111. package/src/vendor/grammars/README.md +27 -2
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * A host outside `lean_projection.hosts` receives EXACTLY what `eager-all` writes.
4
+ *
5
+ * `road-to-delivery-for-every-host` step 1.4, and the gate behind its AC-2.
6
+ *
7
+ * WHY A SEPARATE GATE FROM `check_rule_projection_integrity`
8
+ * ---------------------------------------------------------
9
+ * That gate reads the trees AS THEY STAND — no regeneration, by design, because
10
+ * its job is to catch a STALE tree and regenerating erases the evidence. Its 1.3
11
+ * host axis therefore asks a content question about the entries that exist:
12
+ * "is any of these a stub in a tree that should hold bodies?"
13
+ *
14
+ * That is necessary and it is not sufficient. It cannot see a projector change
15
+ * that alters a non-delivery host's output in some way OTHER than stubbing it —
16
+ * a different frontmatter rewrite, a dropped rule, an added byte. The AC is
17
+ * byte-identity, and byte-identity can only be established by producing both
18
+ * trees and comparing them.
19
+ *
20
+ * So this gate GENERATES, twice, into a temporary root it owns:
21
+ *
22
+ * 1. `mode: eager-all` → the reference trees
23
+ * 2. `mode: delivery`, the configured `hosts:` → the trees under test
24
+ *
25
+ * and requires every tree whose host is NOT in `hosts` to be byte-identical
26
+ * across the two runs. It never writes into the repository: the fixture root is
27
+ * a `mkdtemp` directory, seeded from `dist/agent-src/rules`, and removed on exit.
28
+ *
29
+ * WHAT IT DELIBERATELY DOES NOT DO
30
+ * --------------------------------
31
+ * It does not assert that the DELIVERY host's tree changed. A flip that thins
32
+ * nothing is a different defect and `check_rule_projection_integrity` plus the
33
+ * unit corpus already cover it; asserting it here would make this gate fail on a
34
+ * legitimate `hosts: []`, which is the safe configuration.
35
+ *
36
+ * AND IT ASSERTS NOTHING WHEN EVERY THINNABLE HOST IS ENROLLED. With
37
+ * `hosts: [claude-code, cursor, cline]` there is no non-delivery tree, so the
38
+ * comparison has no subject. That case now prints a `⚠️ NOTHING COMPARED` line
39
+ * naming the gate that does carry the question, instead of the `✅ … 0 …
40
+ * byte-identical` line it printed until 2026-09-08 (R2 finding 12) — a green
41
+ * sentence for an assertion that was never made.
42
+ *
43
+ * IT ALSO DOES NOT CONSULT `lean_projection.mode`, and the divergence from
44
+ * `check_rule_projection_integrity._lean_projection_settings_for` — which
45
+ * checks the mode FIRST so "a `hosts:` list left behind after a rollback
46
+ * exempts nothing" — is deliberate. Recorded 2026-09-08 because R2 finding 13
47
+ * read it as an oversight, and running the mode-first variant here reddens the
48
+ * gate on this repository's own settings, which carry no `lean_projection`
49
+ * block at all.
50
+ *
51
+ * The two gates ask different questions. The sibling asks a question ABOUT THE
52
+ * CURRENT TREE — "is any entry on disk a stub in a tree with no delivery
53
+ * slot?" — so the live mode is part of its subject. This gate asks a
54
+ * COUNTERFACTUAL ABOUT THE PROJECTOR — "under a thinning mode with the
55
+ * configured hosts, would every other host's tree still be byte-identical?" —
56
+ * and supplies `mode: delivery` to its own test fixture to ask it. Reading the
57
+ * live mode would make the exemption set and the fixture disagree: with the
58
+ * mode off, `configured` would be empty while the fixture still thinned, and
59
+ * every stub in the delivery host's tree would be reported as a parity break.
60
+ *
61
+ * The consequence the finding names is real and is not a hole: after a rollback
62
+ * to `eager-all`, a leftover `hosts: [claude-code]` still excludes
63
+ * `.claude/rules` from THIS comparison. Nothing is hidden by that, because
64
+ * under `eager-all` nothing is thinned and the on-disk state is the sibling
65
+ * gate's subject.
66
+ *
67
+ * It also does not read the repository's own `.claude/rules` etc. Those are
68
+ * gitignored and absent on a fresh CI checkout, and a gate that silently passes
69
+ * when its subject is missing is the shape this repository's scan-scope
70
+ * discipline exists to reject — hence the fixture root, which always exists.
71
+ *
72
+ * Exit: 0 parity holds · 1 a non-delivery host's tree differs · 2 the gate could
73
+ * not run (no rule corpus to seed from).
74
+ */
75
+
76
+ import * as fs from 'node:fs';
77
+ import * as os from 'node:os';
78
+ import * as path from 'node:path';
79
+ import { fileURLToPath, pathToFileURL } from 'node:url';
80
+
81
+ import { GateLedger, UnaccountedTargetsError } from './_lib/gate_ledger.js';
82
+ import { leanProjectionHostsRaw } from './_lib/hook_settings.js';
83
+ import { resolveLeanProjectionHosts } from './_lib/lean_projection_mode.js';
84
+ import { reportScanned } from './_lib/scan_scope.js';
85
+ import {
86
+ _getStateForTest,
87
+ _resetStateForTest as _pointCondenseAt,
88
+ generate_rule_symlinks,
89
+ } from './condense.js';
90
+
91
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
92
+ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
93
+
94
+ /** The tool dirs this gate compares, and the host each belongs to. */
95
+ export const TREES: Readonly<Record<string, string>> = {
96
+ '.claude/rules': 'claude-code',
97
+ '.cursor/rules': 'cursor',
98
+ '.clinerules': 'cline',
99
+ };
100
+
101
+ /** Every entry in `dir` as `name -> content`, reading through symlinks. */
102
+ export function treeContents(root: string, rel: string): Record<string, string> {
103
+ const dir = path.join(root, rel);
104
+ const out: Record<string, string> = {};
105
+ if (!fs.existsSync(dir)) return out;
106
+ for (const name of fs.readdirSync(dir).sort()) {
107
+ if (name === 'README.md') continue;
108
+ try {
109
+ out[name] = fs.readFileSync(path.join(dir, name), 'utf-8');
110
+ } catch {
111
+ out[name] = '<unreadable>';
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Copy the rule corpus into a fresh fixture root carrying `settings`. */
118
+ function seedFixture(repoRoot: string, tmp: string, label: string, settings: string): string {
119
+ const root = path.join(tmp, label);
120
+ const dist = path.join(root, 'dist', 'agent-src', 'rules');
121
+ fs.mkdirSync(dist, { recursive: true });
122
+ const src = path.join(repoRoot, 'dist', 'agent-src', 'rules');
123
+ for (const name of fs.readdirSync(src).filter((f) => f.endsWith('.md'))) {
124
+ fs.copyFileSync(path.join(src, name), path.join(dist, name));
125
+ }
126
+ fs.writeFileSync(path.join(root, '.agent-settings.yml'), settings, 'utf-8');
127
+ return root;
128
+ }
129
+
130
+ export interface ParityFinding {
131
+ readonly tree: string;
132
+ readonly host: string;
133
+ readonly rule: string;
134
+ readonly reason: string;
135
+ }
136
+
137
+ /** Compare two generated trees, reporting every difference in a non-delivery host's tree. */
138
+ export function compareTrees(
139
+ eagerRoot: string,
140
+ testRoot: string,
141
+ deliveryHosts: readonly string[],
142
+ ): ParityFinding[] {
143
+ const out: ParityFinding[] = [];
144
+ for (const [tree, host] of Object.entries(TREES)) {
145
+ if (deliveryHosts.includes(host)) continue;
146
+ const a = treeContents(eagerRoot, tree);
147
+ const b = treeContents(testRoot, tree);
148
+ for (const name of new Set([...Object.keys(a), ...Object.keys(b)])) {
149
+ const left = a[name];
150
+ const right = b[name];
151
+ if (left === right) continue;
152
+ const reason =
153
+ left === undefined
154
+ ? 'present under the thinning mode and absent under eager-all'
155
+ : right === undefined
156
+ ? 'written by eager-all and MISSING under the thinning mode'
157
+ : `content differs (${String(left.length)} vs ${String(right.length)} bytes)`;
158
+ out.push({ tree, host, rule: name, reason });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * The notice for a comparison with NO SUBJECT, or `null` when there is one.
166
+ *
167
+ * R2 finding 12: with every thinnable host in `lean_projection.hosts` there is
168
+ * no non-delivery tree left, so this gate's assertion is VACUOUS — and it used
169
+ * to say so inside the same `✅ … byte-identical` sentence it prints when the
170
+ * assertion actually held. Zero compared is not a pass; it is an absence of
171
+ * subject, and the reader has to be told which gate carries the question
172
+ * instead.
173
+ *
174
+ * Deliberately NOT an exit-1: enrolling all three hosts is a legal
175
+ * configuration, and reddening a legal configuration is how a gate gets
176
+ * switched off. Pure and exported so the wording is under test without running
177
+ * the two-fixture generation.
178
+ */
179
+ export function parityScopeNotice(configured: readonly string[]): string | null {
180
+ const comparable = Object.entries(TREES).filter(([, h]) => !configured.includes(h));
181
+ if (comparable.length > 0) return null;
182
+ return (
183
+ `⚠️ check_host_tree_parity: NOTHING COMPARED — every thinnable host is in ` +
184
+ `lean_projection.hosts [${configured.join(', ') || '(none)'}], so no non-delivery tree ` +
185
+ `exists and this gate asserts nothing about this configuration. A stub in a tree with ` +
186
+ `no bound delivery slot is reported by check_rule_projection_integrity's host axis ` +
187
+ `(thinnedTreeFindings); the byte-identity question here has no subject.\n`
188
+ );
189
+ }
190
+
191
+ export function main(argv: readonly string[] = []): number {
192
+ const root = REPO_ROOT;
193
+ const dist = path.join(root, 'dist', 'agent-src', 'rules');
194
+ if (!fs.existsSync(dist) || fs.readdirSync(dist).filter((f) => f.endsWith('.md')).length === 0) {
195
+ process.stderr.write(
196
+ `❌ check_host_tree_parity: no rule corpus at dist/agent-src/rules — run \`task sync\` first.\n`,
197
+ );
198
+ return 2;
199
+ }
200
+ const configured = resolveLeanProjectionHosts(leanProjectionHostsRaw(root)).hosts;
201
+ const hostList = configured.join(', ') || '(none)';
202
+
203
+ const saved = _getStateForTest();
204
+ const savedHome = process.env['HOME'];
205
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'host-parity-'));
206
+ let findings: ParityFinding[];
207
+ try {
208
+ // An empty HOME: user-scope dedup must find no twin, or the two runs
209
+ // would differ for a reason that has nothing to do with the host axis.
210
+ const home = path.join(tmp, 'home');
211
+ fs.mkdirSync(home, { recursive: true });
212
+ process.env['HOME'] = home;
213
+
214
+ const eagerRoot = seedFixture(root, tmp, 'eager', 'lean_projection:\n mode: eager-all\n');
215
+ _pointCondenseAt(eagerRoot);
216
+ generate_rule_symlinks();
217
+
218
+ const hostsBlock = configured.length === 0 ? '' : ` hosts: [${configured.join(', ')}]\n`;
219
+ const testRoot = seedFixture(root, tmp, 'test', `lean_projection:\n mode: delivery\n${hostsBlock}`);
220
+ _pointCondenseAt(testRoot);
221
+ generate_rule_symlinks();
222
+
223
+ findings = compareTrees(eagerRoot, testRoot, configured);
224
+ } finally {
225
+ _pointCondenseAt(saved.PROJECT_ROOT);
226
+ if (savedHome === undefined) delete process.env['HOME'];
227
+ else process.env['HOME'] = savedHome;
228
+ fs.rmSync(tmp, { recursive: true, force: true });
229
+ }
230
+
231
+ const compared = Object.entries(TREES).filter(([, h]) => !configured.includes(h)).length;
232
+ reportScanned({
233
+ gate: 'check_host_tree_parity',
234
+ scanned: compared,
235
+ units: 'non-delivery host rule tree(s)',
236
+ roots: Object.keys(TREES),
237
+ });
238
+
239
+ // One ledger target per non-delivery tree, not per entry: the assertion is
240
+ // about a TREE being byte-identical, and a per-entry ledger would report
241
+ // 200-odd completed targets for what is three yes/no questions.
242
+ const ledger = new GateLedger('check_host_tree_parity');
243
+ const compareable = Object.entries(TREES).filter(([, h]) => !configured.includes(h)).map(([t]) => t);
244
+ ledger.plan(compareable);
245
+ const broken = new Set(findings.map((f) => f.tree));
246
+ for (const tree of compareable) {
247
+ if (broken.has(tree)) ledger.fail(tree, 'differs from the eager-all projection');
248
+ else ledger.complete(tree);
249
+ }
250
+ try {
251
+ if (argv.includes('--quiet')) ledger.finalize();
252
+ else ledger.report();
253
+ } catch (exc) {
254
+ if (exc instanceof UnaccountedTargetsError) {
255
+ process.stderr.write(`❌ ${exc.message}\n`);
256
+ return 1;
257
+ }
258
+ throw exc;
259
+ }
260
+
261
+ if (findings.length > 0) {
262
+ process.stderr.write(
263
+ `❌ check_host_tree_parity: ${String(findings.length)} entry(ies) differ between ` +
264
+ `eager-all and delivery on a host that is NOT in lean_projection.hosts [${hostList}]:\n`,
265
+ );
266
+ for (const f of findings) {
267
+ process.stderr.write(` · ${f.tree}/${f.rule} (${f.host}) — ${f.reason}\n`);
268
+ }
269
+ process.stderr.write(
270
+ '\nA host outside `lean_projection.hosts` must receive exactly what `eager-all` writes ' +
271
+ '(K3, and the completeness invariant). Fix the projector, never the expectation.\n',
272
+ );
273
+ return 1;
274
+ }
275
+ const notice = parityScopeNotice(configured);
276
+ if (notice !== null) {
277
+ process.stderr.write(notice);
278
+ return 0;
279
+ }
280
+ if (!argv.includes('--quiet')) {
281
+ process.stdout.write(
282
+ `✅ check_host_tree_parity: ${String(compared)} non-delivery host tree(s) byte-identical to ` +
283
+ `eager-all · delivery hosts [${hostList}]\n`,
284
+ );
285
+ }
286
+ return 0;
287
+ }
288
+
289
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
290
+ process.exit(main(process.argv.slice(2)));
291
+ }
@@ -18,7 +18,24 @@
18
18
  * bucket has no local source at all — the census reports that one as an explicit
19
19
  * residual rather than pretending to measure it.
20
20
  *
21
- * Exit codes: 0 within budget · 1 over budget · 2 misuse / unreadable budget.
21
+ * THE BOUND IS SHRINK-ONLY, AND NOW CHECKED
22
+ * ------------------------------------------
23
+ * `ci_delivery.why_a_grace_ceiling` says of the grace ceiling *"It may never
24
+ * move UP"*, and `grace_ceiling_history` in the same file records it moving up
25
+ * twice. ADR-264 resolved that against the practice — the sentence stands — and
26
+ * left the sentence unenforced, which is exactly the state that produced both
27
+ * raises. `_lib/standing_bound_ratchet.ts` is the enforcement: the effective
28
+ * ceiling is compared against the one at the base ref and a rise refuses the run,
29
+ * whether it arrived by editing the config or by passing a bigger `--ceiling`.
30
+ *
31
+ * It is also ALL that `road-to-a-standing-budget-with-headroom` step 1.3 ships.
32
+ * That step designed a 128-token Iron Law reserve; an AI council refused to
33
+ * activate it 2/2 on 2026-09-08 because the verifier is inside the change under
34
+ * review, so a protected approval record grants nothing. ADR-265 carries the
35
+ * verdict and the activation prerequisites.
36
+ *
37
+ * Exit codes: 0 within budget · 1 over budget or the bound rose · 2 misuse /
38
+ * unreadable budget.
22
39
  */
23
40
  import { execFileSync } from 'node:child_process';
24
41
  import * as fs from 'node:fs';
@@ -29,6 +46,13 @@ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
29
46
  import { censusClaudeMdHierarchy, censusRuleDir, censusSkillsCatalog } from './preamble_byte_census.js';
30
47
  import { PREFIX_STABLE_SURFACES, prefixStableRoots } from './_lib/prefix_stable_surfaces.js';
31
48
  import { attributeGrowth, buildLedger, renderAttribution } from './_lib/asset_delivery_ledger.js';
49
+ import { resolveBaseRef } from './_lib/ratchet_base_ref.js';
50
+ import {
51
+ assertBoundsDidNotRise,
52
+ type BoundsRatchetVerdict,
53
+ type GitRunner,
54
+ realGit,
55
+ } from './_lib/standing_bound_ratchet.js';
32
56
 
33
57
  const HERE = path.dirname(new URL(import.meta.url).pathname);
34
58
  const REPO_ROOT = path.resolve(HERE, '..', '..');
@@ -38,6 +62,15 @@ interface Budget {
38
62
  baseline_tokens: number;
39
63
  headroom_pct: number;
40
64
  target_tokens: { median: number; p95: number };
65
+ /**
66
+ * `ci_delivery.grace_ceiling`, or `null` when the file carries none.
67
+ *
68
+ * Read here only so the shrink-only ratchet has a head-side value on a local
69
+ * run that passes no `--ceiling`. The number the payload is COMPARED against
70
+ * still arrives through the flag, so this does not become a second home for
71
+ * the ceiling.
72
+ */
73
+ grace_ceiling: number | null;
41
74
  }
42
75
 
43
76
  export interface BudgetVerdict {
@@ -60,10 +93,13 @@ export function readBudget(file: string = BUDGET_FILE): Budget {
60
93
  throw new Error(`${file}: baseline_tokens and headroom_pct must both be numbers`);
61
94
  }
62
95
  const target = (raw['target_tokens'] ?? {}) as Record<string, unknown>;
96
+ const delivery = (raw['ci_delivery'] ?? {}) as Record<string, unknown>;
97
+ const grace = Number(delivery['grace_ceiling']);
63
98
  return {
64
99
  baseline_tokens: baseline,
65
100
  headroom_pct: headroom,
66
101
  target_tokens: { median: Number(target['median']), p95: Number(target['p95']) },
102
+ grace_ceiling: Number.isFinite(grace) ? grace : null,
67
103
  };
68
104
  }
69
105
 
@@ -142,6 +178,47 @@ export function evaluate(repoRoot?: string, budgetFile?: string, overrideCeiling
142
178
  return { measured, baseline: budget.baseline_tokens, ceiling, withinBudget: measured <= ceiling, buckets };
143
179
  }
144
180
 
181
+ /**
182
+ * The measurement plus the shrink-only bound check.
183
+ *
184
+ * `evaluate` above answers "how big is the tree" and touches no ref;
185
+ * this answers "may this tree ship" and needs the base one. They stay separate
186
+ * because a census that silently depended on a remote ref would behave
187
+ * differently in a shallow clone, and the census is the half other callers use.
188
+ */
189
+ export interface Decision {
190
+ verdict: BudgetVerdict;
191
+ bounds: BoundsRatchetVerdict;
192
+ /** False when the payload is over the ceiling, or the ceiling itself rose. */
193
+ ok: boolean;
194
+ }
195
+
196
+ export interface DecideOptions {
197
+ repoRoot?: string;
198
+ budgetFile?: string;
199
+ overrideCeiling?: number;
200
+ env?: NodeJS.ProcessEnv;
201
+ git?: GitRunner;
202
+ /** Test seam: pin the base ref instead of resolving it. `null` = none resolved. */
203
+ baseRef?: string | null;
204
+ }
205
+
206
+ export function decide(opts: DecideOptions = {}): Decision {
207
+ const repoRoot = opts.repoRoot ?? REPO_ROOT;
208
+ const git = opts.git ?? realGit;
209
+ const budget = readBudget(opts.budgetFile);
210
+ const verdict = evaluate(repoRoot, opts.budgetFile, opts.overrideCeiling);
211
+ const baseRef =
212
+ opts.baseRef !== undefined ? opts.baseRef : resolveBaseRef(repoRoot, opts.env ?? process.env, git);
213
+ const bounds = assertBoundsDidNotRise({
214
+ repoRoot,
215
+ baseRef,
216
+ git,
217
+ headGraceCeiling: opts.overrideCeiling ?? budget.grace_ceiling ?? 0,
218
+ });
219
+ return { verdict, bounds, ok: bounds.ok && verdict.withinBudget };
220
+ }
221
+
145
222
  /**
146
223
  * Per-asset attribution for a refusal, against the merge-base tree.
147
224
  *
@@ -203,6 +280,26 @@ function attributeGrowthAgainstBase(): string[] | null {
203
280
  }
204
281
  }
205
282
 
283
+ /**
284
+ * The bound check, rendered for a human — on BOTH paths, green and red.
285
+ *
286
+ * A check that only speaks when it fails is a check nobody audits until it is
287
+ * already load-bearing. Printing the compared ref and the earlier bound on the
288
+ * passing path is also the only way a reader can tell "verified and unchanged"
289
+ * from "skipped because no base ref resolved", which are different facts.
290
+ */
291
+ function renderBounds(b: BoundsRatchetVerdict): string {
292
+ if (b.note !== null) {
293
+ return ` ${'grace ceiling ratchet'.padEnd(38)} ${'SKIPPED'.padStart(8)} — ${b.note}\n`;
294
+ }
295
+ const base = b.baseGraceCeiling === null ? 'n/a' : String(b.baseGraceCeiling);
296
+ const state = b.ok ? 'ok' : 'ROSE';
297
+ return (
298
+ ` ${'grace ceiling ratchet'.padEnd(38)} ${state.padStart(8)} — ` +
299
+ `${base} at ${b.baseRef ?? 'n/a'}, shrink-only (ADR-264)\n`
300
+ );
301
+ }
302
+
206
303
  export function main(argv: string[] = process.argv.slice(2)): number {
207
304
  const json = argv.includes('--format=json') || argv.includes('--json');
208
305
  // `--ceiling <n>`: the grace ceiling the CI step reads out of
@@ -211,13 +308,14 @@ export function main(argv: string[] = process.argv.slice(2)): number {
211
308
  // tighter-than-design value is IGNORED rather than honoured — see `evaluate`.
212
309
  const ci = argv.indexOf('--ceiling');
213
310
  const override = ci !== -1 && argv[ci + 1] !== undefined ? Number(argv[ci + 1]) : undefined;
214
- let verdict: BudgetVerdict;
311
+ let decision: Decision;
215
312
  try {
216
- verdict = evaluate(undefined, undefined, override);
313
+ decision = decide(override === undefined ? {} : { overrideCeiling: override });
217
314
  } catch (err) {
218
315
  process.stderr.write(`❌ preamble-payload budget: ${(err as Error).message}\n`);
219
316
  return 2;
220
317
  }
318
+ const verdict = decision.verdict;
221
319
 
222
320
  // A ratchet over a measurement of nothing always passes: move
223
321
  // `dist/agent-src/` and every census returns zero, which is trivially under
@@ -239,8 +337,24 @@ export function main(argv: string[] = process.argv.slice(2)): number {
239
337
  }
240
338
 
241
339
  if (json) {
242
- process.stdout.write(JSON.stringify(verdict, null, 2) + '\n');
243
- return verdict.withinBudget ? 0 : 1;
340
+ process.stdout.write(
341
+ JSON.stringify(
342
+ {
343
+ ...verdict,
344
+ ok: decision.ok,
345
+ bound_ratchet: {
346
+ ok: decision.bounds.ok,
347
+ base_ref: decision.bounds.baseRef,
348
+ base_grace_ceiling: decision.bounds.baseGraceCeiling,
349
+ note: decision.bounds.note,
350
+ violations: decision.bounds.violations,
351
+ },
352
+ },
353
+ null,
354
+ 2,
355
+ ) + '\n',
356
+ );
357
+ return decision.ok ? 0 : 1;
244
358
  }
245
359
 
246
360
  for (const b of verdict.buckets) {
@@ -252,6 +366,20 @@ export function main(argv: string[] = process.argv.slice(2)): number {
252
366
  ` ${'measured total'.padEnd(38)} ${String(verdict.measured).padStart(8)} tok ` +
253
367
  `(baseline ${verdict.baseline}, ${sign}${delta}; ceiling ${verdict.ceiling})\n`,
254
368
  );
369
+ process.stdout.write(renderBounds(decision.bounds));
370
+
371
+ // The bound is checked BEFORE the size question and independently of it. A
372
+ // change that lifts its own ceiling has already defeated the gate, and
373
+ // reporting that as "within budget" would be the config-weakening move
374
+ // wearing a green checkmark.
375
+ if (!decision.bounds.ok) {
376
+ process.stderr.write(
377
+ '❌ the standing-payload ceiling rose in this change:\n' +
378
+ decision.bounds.violations.map((v) => ` · ${v}\n`).join(''),
379
+ );
380
+ return 1;
381
+ }
382
+
255
383
  if (!verdict.withinBudget) {
256
384
  // road-to-delivered-cost-truth 2.2 — a gate names its own "no". The
257
385
  // ceiling message alone states a fact and leaves the reader to find the
@@ -268,8 +396,9 @@ export function main(argv: string[] = process.argv.slice(2)): number {
268
396
  process.stderr.write(
269
397
  `❌ per-spawn preamble payload grew past the ratchet: ${verdict.measured} > ${verdict.ceiling} tok.\n` +
270
398
  ` Every rule and skill description here is re-written on EVERY subagent spawn, so growth\n` +
271
- ` is paid per spawn, not once. Either shrink the addition, or raise baseline_tokens in\n` +
272
- ` src/config/preamble-payload-budget.json with the reason in the same commit.\n`,
399
+ ` is paid per spawn, not once. Shrink the addition, or migrate the prose out of the\n` +
400
+ ` standing rule. The grace ceiling may NOT be raised to fit it: ADR-264, enforced by\n` +
401
+ ` the shrink-only ratchet above.\n`,
273
402
  );
274
403
  // Three distinct states, and conflating the last two is a diagnostic
275
404
  // defect rather than a cosmetic one: "I could not look" and "I looked
@@ -98,6 +98,13 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
98
98
  import { GateLedger, UnaccountedTargetsError } from './_lib/gate_ledger.js';
99
99
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
100
100
  import { _resetStateForTest as _pointCondenseAt, projected_rule_trees } from './condense.js';
101
+ import { leanProjectionHostsRaw, leanProjectionModeRaw } from './_lib/hook_settings.js';
102
+ import {
103
+ normalizeLeanProjectionMode,
104
+ resolveLeanProjectionHosts,
105
+ writesThinFiles,
106
+ } from './_lib/lean_projection_mode.js';
107
+ import { is_thin_entry } from './project_thin_rules.js';
101
108
 
102
109
  const _HERE = path.resolve(fileURLToPath(import.meta.url));
103
110
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -105,7 +112,24 @@ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
105
112
  /** Where the projected rules are condensed to — the freshness comparison's left side. */
106
113
  const DIST_RULES = path.join('dist', 'agent-src', 'rules');
107
114
 
108
- export type ProjectionFindingKind = 'missing' | 'stale' | 'dangling';
115
+ export type ProjectionFindingKind = 'missing' | 'stale' | 'dangling' | 'thinned';
116
+
117
+ /**
118
+ * Tool dir → the host id whose tree it is.
119
+ *
120
+ * Mirrors `condense._DIR_TOOL_ID` for the three dirs this gate covers. It is a
121
+ * second spelling of a mapping `condense` already owns, which is a real cost —
122
+ * accepted because `_DIR_TOOL_ID` is module-private and exporting it would widen
123
+ * `condense`'s surface for one consumer, in a file already ~1,200 lines past the
124
+ * source-size ceiling where every added line is one unit of ratchet debt.
125
+ * `thinnedTreeFindings` fails CLOSED on an unmapped dir (see there), so a dir
126
+ * added upstream and missed here is reported rather than silently exempted.
127
+ */
128
+ export const TREE_HOST_ID: Readonly<Record<string, string>> = {
129
+ '.claude/rules': 'claude-code',
130
+ '.cursor/rules': 'cursor',
131
+ '.clinerules': 'cline',
132
+ };
109
133
 
110
134
  export interface ProjectionFinding {
111
135
  /** Repo-relative tool dir, e.g. `.claude/rules`. */
@@ -138,9 +162,59 @@ function _lstatOrNull(p: string): fs.Stats | null {
138
162
  * computed here so the caller controls which root the generator's emit plan was
139
163
  * derived from, and so a test can pin an expectation without a settings file.
140
164
  */
165
+ /**
166
+ * The host axis (road-to-delivery-for-every-host 1.3).
167
+ *
168
+ * A stub is a COMPLETE projection for a host inside `lean_projection.hosts` —
169
+ * that is what delivery mode means, and failing it there would make the mode
170
+ * unusable. For every other host a stub is the D1 defect: the body left the
171
+ * tree and no concern is bound to bring it back, so the rule reaches that host
172
+ * at no scope at all while every file-count check still passes.
173
+ *
174
+ * Fails CLOSED in two directions, both deliberate:
175
+ * · a tool dir absent from `TREE_HOST_ID` maps to `''`, which is in no host
176
+ * list, so an unmapped dir is treated as NOT a delivery host and its stubs
177
+ * are reported. A new dir is then a loud finding rather than a silent
178
+ * exemption.
179
+ * · an unreadable entry is skipped rather than guessed at — `missing` and
180
+ * `dangling` already own that case, and inventing a third verdict from a
181
+ * failed read would double-report one defect.
182
+ */
183
+ export function thinnedTreeFindings(
184
+ repoRoot: string,
185
+ expected: Readonly<Record<string, readonly string[]>>,
186
+ deliveryHosts: readonly string[],
187
+ ): ProjectionFinding[] {
188
+ const out: ProjectionFinding[] = [];
189
+ for (const [tree, rules] of Object.entries(expected)) {
190
+ const hostId = TREE_HOST_ID[tree] ?? '';
191
+ if (deliveryHosts.includes(hostId)) continue;
192
+ for (const rule of rules) {
193
+ let text: string;
194
+ try {
195
+ text = fs.readFileSync(path.join(repoRoot, tree, rule), 'utf-8');
196
+ } catch {
197
+ continue;
198
+ }
199
+ if (!is_thin_entry(text)) continue;
200
+ out.push({
201
+ tree,
202
+ rule,
203
+ kind: 'thinned',
204
+ message:
205
+ `pointer stub in a tree whose host (${hostId === '' ? 'unmapped' : hostId}) is not in ` +
206
+ `lean_projection.hosts — the body reaches this host at no scope, and no concern ` +
207
+ `is bound to deliver it back`,
208
+ });
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
141
214
  export function auditRuleProjection(
142
215
  repoRoot: string,
143
216
  expected: Readonly<Record<string, readonly string[]>>,
217
+ deliveryHosts: readonly string[] = [],
144
218
  ): ProjectionAudit {
145
219
  const ledger = new GateLedger('check_rule_projection_integrity');
146
220
  const findings: ProjectionFinding[] = [];
@@ -248,9 +322,34 @@ export function auditRuleProjection(
248
322
  }
249
323
  }
250
324
 
325
+ // Appended after the completeness/freshness pass rather than folded into it:
326
+ // a stub entry EXISTS and is FRESH, so it is legitimately `complete` on the
327
+ // ledger. The host axis is a separate question about the entry's CONTENT,
328
+ // and conflating the two would make a delivery host's stub a ledger failure.
329
+ findings.push(...thinnedTreeFindings(repoRoot, expected, deliveryHosts));
330
+
251
331
  return { findings, treePresent, ledger };
252
332
  }
253
333
 
334
+ /**
335
+ * The `lean_projection` block as it stands under `root`.
336
+ *
337
+ * Read with the HOOK-side indentation reader rather than a YAML parse, on
338
+ * purpose: this gate runs first in the pre-push chain and on a fresh CI
339
+ * checkout, and a parser failure there must not fail a rule audit for a reason
340
+ * unrelated to rules. `_lib/lean_projection_mode.ts` owns what the values MEAN,
341
+ * so this gate and the projector cannot disagree.
342
+ *
343
+ * `mode` is checked before `hosts`: with the mode off no host is a delivery
344
+ * host, so a `hosts:` list left behind after a rollback exempts nothing and
345
+ * every stub in every tree is reported.
346
+ */
347
+ function _lean_projection_settings_for(root: string): { hosts: readonly string[] } {
348
+ const mode = normalizeLeanProjectionMode(leanProjectionModeRaw(root));
349
+ if (!writesThinFiles(mode)) return { hosts: [] };
350
+ return { hosts: resolveLeanProjectionHosts(leanProjectionHostsRaw(root)).hosts };
351
+ }
352
+
254
353
  /** Group findings by tree, preserving rule order, for a report a reader can act on. */
255
354
  export function renderFindings(audit: ProjectionAudit): string[] {
256
355
  const lines: string[] = [];
@@ -297,7 +396,8 @@ export function main(argv?: readonly string[]): number {
297
396
 
298
397
  let audit: ProjectionAudit;
299
398
  try {
300
- audit = auditRuleProjection(root, projected_rule_trees());
399
+ const lean = _lean_projection_settings_for(root);
400
+ audit = auditRuleProjection(root, projected_rule_trees(), lean.hosts);
301
401
  } catch (exc) {
302
402
  if (exc instanceof DeadScopeError) {
303
403
  process.stderr.write(`❌ ${exc.message}\n`);