@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +159 -380
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/merge.md +47 -38
- package/dist/agent-src/commands/roadmap/next.md +8 -0
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
- package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -1
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/archive_completed_roadmaps.js +52 -4
- package/dist/cli-delegate/{chunk-TGIPZBCW.js → chunk-2KKXJW25.js} +22 -8
- package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
- package/dist/cli-delegate/{chunk-BIXEDQPZ.js → chunk-KCNTUSPI.js} +1 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +20 -12
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +3 -13
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +880 -289
- package/dist/install/install.mjs +15 -3
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +5458 -2453
- package/dist/scripts/_lib/lean_projection_mode.js +108 -0
- package/dist/scripts/_lib/router_match.js +243 -0
- package/dist/scripts/_lib/rule_injection.js +216 -0
- package/dist/scripts/condense.js +20 -20
- package/dist/scripts/project_thin_rules.js +139 -19
- package/dist/server/schemas/settings.js +16 -3
- package/docs/MIGRATION.md +56 -0
- package/docs/contracts/settings-classes.md +39 -7
- package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
- package/src/config/agent-settings.template.yml +47 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/hook-token-budget.json +7 -6
- package/src/config/packed-binary-manifest.json +1 -1
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
- package/src/scripts/_cli/handoff_sessions.ts +14 -44
- package/src/scripts/_lib/continuity_slot.ts +295 -0
- package/src/scripts/_lib/continuity_writer.ts +253 -0
- package/src/scripts/_lib/hook_settings.ts +85 -0
- package/src/scripts/_lib/lean_projection_mode.ts +127 -0
- package/src/scripts/_lib/packed_binary_predicate.ts +37 -0
- package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
- package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
- package/src/scripts/_lib/session_eol.ts +44 -0
- package/src/scripts/_lib/session_index_trust.ts +346 -0
- package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
- package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
- package/src/scripts/audit_mcp_tools.ts +69 -25
- package/src/scripts/build_mcp_catalog.ts +24 -1
- package/src/scripts/check_host_tree_parity.ts +291 -0
- package/src/scripts/check_preamble_payload_budget.ts +136 -7
- package/src/scripts/check_rule_projection_integrity.ts +102 -2
- package/src/scripts/ci_settle.ts +205 -5
- package/src/scripts/code_graph/build.ts +79 -0
- package/src/scripts/code_graph/cli.ts +258 -7
- package/src/scripts/code_graph/detect.ts +38 -0
- package/src/scripts/code_graph/query.ts +25 -2
- package/src/scripts/code_graph/sqlite_store.ts +16 -1
- package/src/scripts/code_graph/types.ts +52 -3
- package/src/scripts/code_graph/validate.ts +3 -1
- package/src/scripts/code_graph/verbs.ts +590 -0
- package/src/scripts/condense.ts +20 -20
- package/src/scripts/handoff_context_hook.ts +39 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +24 -1
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
- package/src/scripts/hooks/code_graph_context_hook.ts +7 -18
- package/src/scripts/hooks/push_settle_hook.ts +3 -2
- package/src/scripts/hooks/rule_inject_hook.ts +107 -20
- package/src/scripts/hooks/session_eol_hook.ts +120 -2
- package/src/scripts/hot_context_hook.ts +62 -7
- package/src/scripts/lint_roadmap_blockers.ts +164 -1
- package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
- package/src/scripts/mcp_server/graph_tools.ts +294 -0
- package/src/scripts/mcp_server/path_util.ts +31 -0
- package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
- package/src/scripts/mcp_server/tools.ts +3 -13
- package/src/scripts/model_rule_injection.ts +61 -4
- package/src/scripts/project_thin_rules.ts +149 -16
- package/src/scripts/report_standing_payload_by_host.ts +650 -0
- package/src/scripts/schemas/agent-settings.schema.json +7 -1
- package/src/scripts/session_memory_index.ts +81 -8
- package/src/server/schemas/settings.ts +23 -4
- 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
|
-
*
|
|
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
|
|
311
|
+
let decision: Decision;
|
|
215
312
|
try {
|
|
216
|
-
|
|
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(
|
|
243
|
-
|
|
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.
|
|
272
|
-
`
|
|
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
|
-
|
|
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`);
|