simt-dev 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -0,0 +1,92 @@
1
+ // The names a reader needs before it can open anything that was kept out of the region.
2
+ //
3
+ // A scoped profile and an installed skill are the same situation: the body is not in the
4
+ // file the reader loads at session start, and a reader that is not told the name will
5
+ // never ask for it. This module reads only what the index line needs — a skill's name and
6
+ // its description — and never the body. The body is the cost the index exists to avoid.
7
+
8
+ import { readdir, readFile, realpath } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+
11
+ import { readFrontmatter } from '../graph/spec.mjs';
12
+
13
+ /**
14
+ * Where an installed skill can live. These are the directories `skills install` writes
15
+ * and the directories the loader table records, which are the same four. A fifth would
16
+ * be a place this tool can write a skill and then fail to tell the reader it is there.
17
+ */
18
+ const SKILL_DIRS = ['.agents/skills', '.cursor/skills', '.claude/skills', '.opencode/skills'];
19
+
20
+ /**
21
+ * The skills installed in a repository, one entry per file.
22
+ *
23
+ * A symlink fan-out is one skill: the file is counted by its real path, so the same
24
+ * body linked from two of these directories produces one line. Two different files that
25
+ * share a name both stay, because dropping one would hide a trigger.
26
+ *
27
+ * @param {string} root
28
+ * @returns {Promise<{name: string, trigger: string, dir: string}[]>}
29
+ */
30
+ export async function installedSkills(root) {
31
+ const seen = new Set();
32
+ const skills = [];
33
+ for (const dir of SKILL_DIRS) {
34
+ let entries;
35
+ try {
36
+ entries = await readdir(join(root, dir), { withFileTypes: true });
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue;
39
+ throw error;
40
+ }
41
+ for (const entry of entries) {
42
+ const file = await skillFile(root, dir, entry);
43
+ if (file === null) continue;
44
+ let key;
45
+ try {
46
+ key = await realpath(file);
47
+ } catch (error) {
48
+ if (error.code === 'ENOENT') continue;
49
+ throw error;
50
+ }
51
+ if (seen.has(key)) continue;
52
+ seen.add(key);
53
+ let text;
54
+ try {
55
+ text = await readFile(key, 'utf8');
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT') continue;
58
+ throw error;
59
+ }
60
+ const { meta } = readFrontmatter(text);
61
+ const fallback = entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/, '');
62
+ const name = typeof meta.name === 'string' && meta.name !== '' ? meta.name : fallback;
63
+ const trigger = typeof meta.description === 'string' ? meta.description.replace(/\s+/g, ' ').trim() : '';
64
+ skills.push({ name, trigger, dir });
65
+ }
66
+ }
67
+ skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
68
+ return skills;
69
+ }
70
+
71
+ /**
72
+ * The file that holds one skill, or null when the entry is not a skill.
73
+ *
74
+ * A symlink is the fan-out case: the directory a reader opens is a link, and
75
+ * `Dirent.isDirectory` is false for it, so treating "not a directory" as "not a
76
+ * skill" would index the canonical copy and then miss a repository whose only
77
+ * copy is the link.
78
+ */
79
+ async function skillFile(root, dir, entry) {
80
+ const base = join(root, dir, entry.name);
81
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
82
+ const nested = join(base, 'SKILL.md');
83
+ try {
84
+ await realpath(nested);
85
+ return nested;
86
+ } catch (error) {
87
+ if (error.code !== 'ENOENT') throw error;
88
+ }
89
+ }
90
+ if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.md')) return base;
91
+ return null;
92
+ }
@@ -0,0 +1,125 @@
1
+ // The lock records the two digests a frozen check compares (ADR-086).
2
+ //
3
+ // Input is everything the projection reads that a repository can change, plus the
4
+ // digest of what this package ships. Output is the region body. An output that moves
5
+ // while the input does not is a renderer that is not a function of its inputs, and
6
+ // that is the failure `--frozen` exists to catch. An input that moves is a stale
7
+ // lock: the projection changed because the repository did, and the lock was not
8
+ // rewritten.
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+
14
+ import { readLockedVariants } from '../kernel/variants.mjs';
15
+ import { fingerprint } from '../resolve/fingerprint.mjs';
16
+ import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
17
+ import { readManifest } from './manifest.mjs';
18
+
19
+ export const LOCK_FILE = 'simt-dev.lock.json';
20
+
21
+ /** Manifest keys that change what the region contains. Anything else in the file is
22
+ * the repository's own notes, and hashing it would make a comment fail the lock. */
23
+ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
24
+
25
+ /**
26
+ * The lock this repository's projection should have.
27
+ *
28
+ * @param {string} root
29
+ * @param {object} [flags] the same flags `sync` rendered with, so the output is the
30
+ * body that was written and not a body for a different set of surfaces
31
+ */
32
+ export async function computeLock(root, flags = {}) {
33
+ const variants =
34
+ flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
35
+ const desired = await desiredRegion(root, { ...flags, variants });
36
+ const inputs = await inputDigest(root, variants);
37
+ const outputs = createHash('sha256').update(desired.body).digest('hex');
38
+ const hasVariants =
39
+ variants && typeof variants === 'object' && Object.keys(variants).length > 0;
40
+ return hasVariants ? { inputs, outputs, variants } : { inputs, outputs };
41
+ }
42
+
43
+ /**
44
+ * Record locked variant winners in `simt-dev.lock.json` alongside updated digests.
45
+ */
46
+ export async function writeLockVariants(root, nextVariants, flags = {}) {
47
+ const existing = await readLockedVariants(root);
48
+ const merged = { ...existing, ...(nextVariants ?? {}) };
49
+ const lock = await computeLock(root, { ...flags, variants: merged });
50
+ await writeFile(join(root, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`, 'utf8');
51
+ return lock;
52
+ }
53
+
54
+ /** Read a lock, or null when the file is not there. A file that does not parse is an
55
+ * error the caller reports, not a missing lock: absence and damage are different
56
+ * repairs. */
57
+ export async function readLock(root) {
58
+ let text;
59
+ try {
60
+ text = await readFile(join(root, LOCK_FILE), 'utf8');
61
+ } catch (error) {
62
+ if (error.code === 'ENOENT') return { lock: null, error: null };
63
+ throw error;
64
+ }
65
+ try {
66
+ const lock = JSON.parse(text);
67
+ if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
68
+ return { lock: null, error: 'must be an object with inputs and outputs' };
69
+ }
70
+ if (
71
+ lock.variants !== undefined &&
72
+ (typeof lock.variants !== 'object' || lock.variants === null || Array.isArray(lock.variants))
73
+ ) {
74
+ return { lock: null, error: 'variants must be an object' };
75
+ }
76
+ return { lock, error: null };
77
+ } catch (error) {
78
+ return { lock: null, error: error.message };
79
+ }
80
+ }
81
+
82
+ async function inputDigest(root, variants = {}) {
83
+ const hash = createHash('sha256');
84
+ hash.update(`provenance:${await provenanceDigest()}\n`);
85
+ const { stack, facts } = await fingerprint(root);
86
+ hash.update(`signals:${JSON.stringify({ stack, facts })}\n`);
87
+ const { manifest } = await readManifest(root);
88
+ const owned = {};
89
+ if (manifest && typeof manifest === 'object') {
90
+ for (const key of MANIFEST_KEYS) {
91
+ if (manifest[key] !== undefined) owned[key] = manifest[key];
92
+ }
93
+ }
94
+ hash.update(`manifest:${JSON.stringify(owned)}\n`);
95
+ let names = [];
96
+ try {
97
+ names = (await readdir(join(root, LOCAL_PROFILE_DIR))).filter((n) => n.endsWith('.md')).sort();
98
+ } catch (error) {
99
+ if (error.code !== 'ENOENT') throw error;
100
+ }
101
+ for (const name of names) {
102
+ const bytes = await readFile(join(root, LOCAL_PROFILE_DIR, name));
103
+ hash.update(`profile:${name}:${bytes.length}:`);
104
+ hash.update(bytes);
105
+ }
106
+ try {
107
+ const raw = await readFile(join(root, 'package.json'), 'utf8');
108
+ const scripts = JSON.parse(raw).scripts ?? {};
109
+ hash.update(`scripts:${JSON.stringify(scripts)}\n`);
110
+ } catch (error) {
111
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
112
+ }
113
+ const variantKeys =
114
+ variants && typeof variants === 'object' ? Object.keys(variants).sort() : [];
115
+ if (variantKeys.length > 0) {
116
+ const winners = {};
117
+ for (const key of variantKeys) {
118
+ const entry = variants[key];
119
+ winners[key] =
120
+ typeof entry === 'string' ? entry : (entry?.winner ?? entry?.variant ?? null);
121
+ }
122
+ hash.update(`variants:${JSON.stringify(winners)}\n`);
123
+ }
124
+ return hash.digest('hex');
125
+ }
@@ -463,6 +463,15 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
463
463
  // compliance (plan 0003 R02) — a mode chosen by default would be a bet the
464
464
  // repository never made. Declared, not generated, for the same reason as `budget`.
465
465
  ...(existing?.mode === undefined ? {} : { mode: existing.mode }),
466
+ // ADR-084: the rules this repository hands to the workspace's user-level copy, and
467
+ // the level that copy lives at. Declared, not generated, for the same reason as
468
+ // `budget` and `mode`: `shared install` writes the list once, but the bytes that land
469
+ // in a repository's region depend on it on every `sync` — so if `upgrade` rewrote it
470
+ // from its own opinion of what a workspace shares, a repository's region would change
471
+ // without anybody asking, which is the failure `budget` moving by itself was.
472
+ // Absent means "no repository in this workspace shares anything", which is the state
473
+ // every repository is in until somebody runs `shared install`.
474
+ ...(existing?.shared === undefined ? {} : { shared: existing.shared }),
466
475
  // ADR-020: which editors this repo's rules are projected into. Absent means the
467
476
  // global standard applies; an explicit empty list is a real choice meaning
468
477
  // AGENTS.md only. Recorded rather than inferred, because "which IDE does this repo
@@ -631,6 +640,24 @@ function defaultId(root) {
631
640
  */
632
641
  export const DEFAULT_BUDGET_BYTES = 30000;
633
642
 
643
+ /**
644
+ * The budget a repository declared, or the default it gets for declaring nothing.
645
+ *
646
+ * One reader for one number, and it exists because two callers need it and a third
647
+ * already had its own: the packer that cuts a kernel to fit (ADR-083) and the meter that
648
+ * reports what it carries, against a ceiling each would otherwise spell differently. The
649
+ * byte they disagree about is the byte the repository wrote down.
650
+ *
651
+ * `0` is honoured — "this repository wants an empty projection" is a decision somebody can
652
+ * make. `null`, a string and `NaN` are not budgets at all, and a hand-edited manifest is
653
+ * exactly where one turns up; the default stands in rather than the ceiling becoming a
654
+ * string that every comparison against it silently passes.
655
+ */
656
+ export function declaredBudget(manifest) {
657
+ const declared = manifest?.budget?.bytes;
658
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_BUDGET_BYTES;
659
+ }
660
+
634
661
  /**
635
662
  * Report a projection that carries more than the repository agreed to carry.
636
663
  *
@@ -667,6 +694,31 @@ export function budgetFinding(bytes, budget, mode = 'full') {
667
694
  };
668
695
  }
669
696
 
697
+ /**
698
+ * The workspace-shared rules this repository declared, as a set of ids.
699
+ *
700
+ * One reader, because the region, `doctor` and `shared` all need the same answer and a
701
+ * projection that renders a delta the meter cannot see is the one-question-two-answers
702
+ * defect this repository exists to end.
703
+ *
704
+ * `level` is read and validated rather than assumed, because `user` is the only level any
705
+ * reader loads from outside a repository and a hand-edited manifest is exactly where a
706
+ * second value turns up. A list that is not a list of strings yields an empty set: the
707
+ * repository then projects everything it selects, which is the state it was in before
708
+ * anybody ran `shared install`, and is never a state that loses a rule.
709
+ */
710
+ export function declaredShared(manifest) {
711
+ const shared = manifest?.shared;
712
+ if (shared === undefined || shared === null) return { level: null, rules: new Set() };
713
+ const level = shared.level === 'user' ? 'user' : null;
714
+ const rules = new Set(Array.isArray(shared.rules) ? shared.rules.filter((id) => typeof id === 'string') : []);
715
+ // A level of `user` with nothing in it is a declaration that changes nothing, and a
716
+ // level that is not `user` names a place this tool cannot write — so neither is a
717
+ // reason to drop rules out of a region.
718
+ if (level === null || rules.size === 0) return { level: null, rules: new Set() };
719
+ return { level, rules };
720
+ }
721
+
670
722
  export const GENERATED_KEYS = new Set(['$comment', 'projection']);
671
723
 
672
724
  /**
@@ -189,6 +189,57 @@ function readSha(body, style) {
189
189
  * never drift independently of it.
190
190
  */
191
191
  export function compose(body, { sha, sources = [] } = {}, style) {
192
+ return render(body, { sha, sources }, style, (s, sha, sources) =>
193
+ [
194
+ s.line(`${MARKER_PREFIX}:generated`),
195
+ s.line(`${MARKER_PREFIX}:sha=${sha}`),
196
+ ...(sources.length > 0 ? [s.line(`${MARKER_PREFIX}:sources=${sources.join(',')}`)] : []),
197
+ // Addressed to whoever reads this repo, not to whoever owns the rules upstream.
198
+ // "Edit the source in simt-dev" was the wrong instruction twice over: a consumer
199
+ // of the published package has no such tree, and an agent reading this repo has
200
+ // no business editing the region it was told not to edit. The only two actions
201
+ // available to a reader are: don't edit it, or regenerate it.
202
+ s.note(
203
+ 'Generated content. Do not edit by hand - your changes are overwritten.',
204
+ `Run \`${CLI} sync --yes\` to regenerate, or \`${CLI} check\` to see what is stale.`,
205
+ ),
206
+ ],
207
+ );
208
+ }
209
+
210
+ /**
211
+ * The user-level shared region (ADR-084).
212
+ *
213
+ * A different note from `compose`, and the difference is the whole point of having two
214
+ * functions. `compose`'s remedy is `simt sync --yes`, and that remedy is wrong for a file
215
+ * in the reader's home directory: `sync` acts on a repository, so a reader who follows
216
+ * the instruction inside `~/.config/opencode/AGENTS.md` runs a command that will not
217
+ * repair it and concludes the projection is broken. The stamp and the marker grammar are
218
+ * shared, so `parse`, `inject` and `diff` read this region exactly as they read a
219
+ * repository's own — only the two sentences a reader acts on differ.
220
+ */
221
+ export function composeShared(body, { sha, sources = [] } = {}, style) {
222
+ return render(body, { sha, sources }, style, (s, sha, sources) =>
223
+ [
224
+ s.line(`${MARKER_PREFIX}:generated`),
225
+ s.line(`${MARKER_PREFIX}:sha=${sha}`),
226
+ ...(sources.length > 0 ? [s.line(`${MARKER_PREFIX}:sources=${sources.join(',')}`)] : []),
227
+ s.note(
228
+ 'Generated content shared by every repository in this workspace. Do not edit it by hand - your changes are overwritten.',
229
+ `Run \`${CLI} shared install --yes\` to regenerate it, or \`${CLI} doctor\` to see whether this copy is stale.`,
230
+ ),
231
+ ],
232
+ );
233
+ }
234
+
235
+ /**
236
+ * A region, markers and provenance header included, with a header this module chose.
237
+ *
238
+ * Split out because two headers now exist and the trailing-newline and empty-body cases
239
+ * below are load-bearing twice over — the formatter fight the header shape prevents does
240
+ * not care which of the two files it is happening in.
241
+ */
242
+ function render(body, { sha, sources = [] }, style, headerLines) {
192
243
  const s = styleOf(style);
193
244
  // A stamp this tool cannot read back is worse than no stamp: `parse` matches
194
245
  // `[0-9a-f]{7,40}`, so the old `unknown` fallback produced a region that reported
@@ -200,21 +251,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
200
251
  }
201
252
  // Write-new, like the markers: the legacy spelling is read but never emitted, so a
202
253
  // migrated region is stable and a legacy one drifts exactly once.
203
- const header = [s.line(`${MARKER_PREFIX}:generated`), s.line(`${MARKER_PREFIX}:sha=${sha}`)];
204
- if (sources.length > 0) {
205
- header.push(s.line(`${MARKER_PREFIX}:sources=${sources.join(',')}`));
206
- }
207
- // Addressed to whoever reads this repo, not to whoever owns the rules upstream.
208
- // "Edit the source in simt-dev" was the wrong instruction twice over: a consumer
209
- // of the published package has no such tree, and an agent reading this repo has
210
- // no business editing the region it was told not to edit. The only two actions
211
- // available to a reader are: don't edit it, or regenerate it.
212
- header.push(
213
- s.note(
214
- 'Generated content. Do not edit by hand - your changes are overwritten.',
215
- `Run \`${CLI} sync --yes\` to regenerate, or \`${CLI} check\` to see what is stale.`,
216
- ),
217
- );
254
+ const header = headerLines(s, sha, sources);
218
255
  const trimmed = body.replace(/\n+$/, '');
219
256
  // Two lines here exist only because a real repo proved they are needed. A
220
257
  // formatter that runs on commit - several repositories have one, and three of the
@@ -269,7 +306,9 @@ function splice(state, region, where) {
269
306
  * adopted?" — `region: 'absent'` is that answer, and reading it off `previousBytes` gets a
270
307
  * repository with hand-written prose and no region wrong.
271
308
  *
272
- * @param {object} [opts] `style` overrides the syntax; `where` overrides placement.
309
+ * @param {object} [opts] `style` overrides the syntax; `where` overrides placement;
310
+ * `compose` overrides which header the region carries (`composeShared` for the
311
+ * user-level file, whose remedy is not `sync`).
273
312
  * @returns {{text: string, action: 'added'|'updated'|'unchanged'|'malformed', reason?: string,
274
313
  * region: 'absent'|'ok'|'malformed', previousBody: string}}
275
314
  */
@@ -281,7 +320,7 @@ export function inject(text, body, meta, opts = {}) {
281
320
  if (state.status === 'malformed') {
282
321
  return { ...seen, text, action: 'malformed', reason: state.reason, line: state.line };
283
322
  }
284
- const region = compose(body, meta, s);
323
+ const region = (opts.compose ?? compose)(body, meta, s);
285
324
 
286
325
  if (state.status === 'absent') {
287
326
  if (where === 'top') {
@@ -26,6 +26,7 @@ import { checkTsconfig } from '../tsconfig.mjs';
26
26
  import { checkToolchain } from '../toolchain.mjs';
27
27
  import { diff, parse, inject, BEGIN, END } from './markers.mjs';
28
28
  import { budgetFinding, readManifest } from './manifest.mjs';
29
+ import { budgetCutFinding } from '../kernel/budget.mjs';
29
30
  import { measureRoot } from '../measure/context.mjs';
30
31
  import { loadMdcRules, renderMdc } from './mdc.mjs';
31
32
  import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
@@ -165,23 +166,6 @@ export async function checkTargets(root, flags = {}) {
165
166
  findings.push(...readLimitFindings(text, PRIMARY_TARGET));
166
167
  }
167
168
 
168
- // The budget: what this repository agreed to carry, against what it carries.
169
- //
170
- // Measured from the same `desiredRegion` the region above was compared against, plus
171
- // the rule files this function went on to compare, because a gate and a meter that
172
- // each counted their own set would report two different totals for one repository and
173
- // the reader would have no way to tell which was the budget. Both numbers are the
174
- // DESIRED ones, so a drifted repo is reported against what it would carry after a
175
- // sync rather than what it carries today.
176
- //
177
- // Non-blocking by design: nothing is cut, because there is nothing to cut yet (ADR-072).
178
- const budgetTotal = await alwaysOnBytes(root);
179
- if (budgetTotal !== null) {
180
- const { manifest } = await readManifest(root);
181
- const over = budgetFinding(budgetTotal.bytes, manifest?.budget, manifest?.mode);
182
- if (over) findings.push({ ...over, file: MANIFEST_FILE, detail: over.detail });
183
- }
184
-
185
169
  // Every package of a monorepo is a projection root in its own right, so the gate
186
170
  // covers it the way it covers the root. A nested region that drifts is as stale as
187
171
  // the root's region, and the reader working in that package reads only that file.
@@ -246,6 +230,29 @@ export async function checkTargets(root, flags = {}) {
246
230
  const all = await loadMdcRules();
247
231
  const desired = await desiredRegion(root, flags);
248
232
  const { stack, facts, manifest } = desired;
233
+
234
+ // The budget, both halves of it, and in that order: what this repository agreed to
235
+ // carry against what it carries, then what was cut to get there.
236
+ //
237
+ // The total comes from the meter rather than from a sum this file could do itself,
238
+ // because a gate and a meter that each counted their own set would report two different
239
+ // numbers for one repository and the reader would have no way to tell which was the
240
+ // budget (ADR-072). The cut comes from the projection itself for the same reason — the
241
+ // packer wrote it, so the packer's own record of it is the one that cannot disagree with
242
+ // what `sync` would write.
243
+ //
244
+ // Both are non-blocking, for the reason ADR-072 gave for the first and ADR-083 for the
245
+ // second: the remedy is a decision about the number, not a missing file.
246
+ const budgetTotal = await alwaysOnBytes(root);
247
+ if (budgetTotal !== null) {
248
+ const over = budgetFinding(budgetTotal.bytes, manifest?.budget, manifest?.mode);
249
+ if (over) findings.push({ ...over, file: MANIFEST_FILE, detail: over.detail });
250
+ }
251
+ const packed = desired.budget;
252
+ if (packed !== null && packed.cuts.length > 0) {
253
+ findings.push(budgetCutFinding({ file: PRIMARY_TARGET, ...packed }));
254
+ }
255
+
249
256
  const declared = await readDeclared(root);
250
257
  const resolved = resolveSurfaces(manifest, flags);
251
258
  for (const p of resolved.problems) {