simt-dev 0.2.2 → 0.3.1

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 (96) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +129 -75
  3. package/bin/simt-dev.mjs +21 -2
  4. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  5. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  6. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  7. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  8. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  9. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  10. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  11. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  12. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  13. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  14. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  15. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  16. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  17. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  18. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  19. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  20. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  21. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  22. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  23. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  24. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  25. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  26. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  27. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  28. package/package.json +2 -1
  29. package/rules/core.md +1 -1
  30. package/src/agenthooks/decide.mjs +123 -0
  31. package/src/agenthooks/policy.mjs +78 -0
  32. package/src/agenthooks/render.mjs +240 -0
  33. package/src/argv.mjs +54 -1
  34. package/src/bench/ablate.mjs +630 -0
  35. package/src/bench/adapters.json +149 -0
  36. package/src/bench/grade.mjs +229 -0
  37. package/src/bench/mine.mjs +811 -0
  38. package/src/bench/probes.mjs +1350 -0
  39. package/src/bench/report.mjs +899 -0
  40. package/src/bench/run.mjs +1002 -0
  41. package/src/ci/workflow.mjs +11 -1
  42. package/src/commands/agenthook.mjs +282 -0
  43. package/src/commands/bench.mjs +371 -0
  44. package/src/commands/evidence.mjs +96 -0
  45. package/src/commands/gate.mjs +165 -0
  46. package/src/commands/harness.mjs +156 -5
  47. package/src/commands/help.mjs +89 -1
  48. package/src/commands/hooks.mjs +45 -5
  49. package/src/commands/impact.mjs +205 -0
  50. package/src/commands/index.mjs +60 -1
  51. package/src/commands/map.mjs +91 -0
  52. package/src/commands/plan.mjs +99 -0
  53. package/src/commands/repoindex.mjs +52 -0
  54. package/src/commands/shared.mjs +398 -0
  55. package/src/commands/sync.mjs +72 -3
  56. package/src/commands/tests.mjs +192 -0
  57. package/src/commands/tokens.mjs +61 -1
  58. package/src/commands/why.mjs +177 -0
  59. package/src/decisions/index.mjs +23 -5
  60. package/src/graph/spec.mjs +1 -1
  61. package/src/hooks/dispatcher.mjs +15 -0
  62. package/src/hooks/shim.mjs +31 -0
  63. package/src/index/compact.mjs +228 -0
  64. package/src/index/extract.mjs +331 -0
  65. package/src/index/gate.mjs +101 -0
  66. package/src/index/govern.mjs +222 -0
  67. package/src/index/impact.mjs +186 -0
  68. package/src/index/map.mjs +393 -0
  69. package/src/index/persist.mjs +172 -0
  70. package/src/index/store.mjs +301 -0
  71. package/src/index/tests.mjs +294 -0
  72. package/src/kernel/budget.mjs +100 -0
  73. package/src/kernel/card.mjs +254 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/ledger/index.mjs +112 -0
  77. package/src/mcp/resources.mjs +157 -0
  78. package/src/mcp/server.mjs +37 -6
  79. package/src/mcp/tools.mjs +84 -0
  80. package/src/measure/context.mjs +184 -11
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/plan/index.mjs +650 -0
  84. package/src/plan/template.mjs +331 -0
  85. package/src/projection/disclosure.mjs +92 -0
  86. package/src/projection/loaders.mjs +689 -0
  87. package/src/projection/lock.mjs +93 -0
  88. package/src/projection/manifest.mjs +78 -3
  89. package/src/projection/markers.mjs +56 -17
  90. package/src/projection/project.mjs +24 -17
  91. package/src/projection/rules.mjs +452 -43
  92. package/src/projection/shared.mjs +428 -0
  93. package/src/projection/surfaces.mjs +22 -8
  94. package/src/projection/targets.mjs +2 -1
  95. package/src/toolwords.mjs +71 -0
  96. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,93 @@
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 } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+
14
+ import { fingerprint } from '../resolve/fingerprint.mjs';
15
+ import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
16
+ import { readManifest } from './manifest.mjs';
17
+
18
+ export const LOCK_FILE = 'simt-dev.lock.json';
19
+
20
+ /** Manifest keys that change what the region contains. Anything else in the file is
21
+ * the repository's own notes, and hashing it would make a comment fail the lock. */
22
+ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
23
+
24
+ /**
25
+ * The lock this repository's projection should have.
26
+ *
27
+ * @param {string} root
28
+ * @param {object} [flags] the same flags `sync` rendered with, so the output is the
29
+ * body that was written and not a body for a different set of surfaces
30
+ */
31
+ export async function computeLock(root, flags = {}) {
32
+ const desired = await desiredRegion(root, flags);
33
+ return {
34
+ inputs: await inputDigest(root),
35
+ outputs: createHash('sha256').update(desired.body).digest('hex'),
36
+ };
37
+ }
38
+
39
+ /** Read a lock, or null when the file is not there. A file that does not parse is an
40
+ * error the caller reports, not a missing lock: absence and damage are different
41
+ * repairs. */
42
+ export async function readLock(root) {
43
+ let text;
44
+ try {
45
+ text = await readFile(join(root, LOCK_FILE), 'utf8');
46
+ } catch (error) {
47
+ if (error.code === 'ENOENT') return { lock: null, error: null };
48
+ throw error;
49
+ }
50
+ try {
51
+ const lock = JSON.parse(text);
52
+ if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
53
+ return { lock: null, error: 'must be an object with inputs and outputs' };
54
+ }
55
+ return { lock, error: null };
56
+ } catch (error) {
57
+ return { lock: null, error: error.message };
58
+ }
59
+ }
60
+
61
+ async function inputDigest(root) {
62
+ const hash = createHash('sha256');
63
+ hash.update(`provenance:${await provenanceDigest()}\n`);
64
+ const { stack, facts } = await fingerprint(root);
65
+ hash.update(`signals:${JSON.stringify({ stack, facts })}\n`);
66
+ const { manifest } = await readManifest(root);
67
+ const owned = {};
68
+ if (manifest && typeof manifest === 'object') {
69
+ for (const key of MANIFEST_KEYS) {
70
+ if (manifest[key] !== undefined) owned[key] = manifest[key];
71
+ }
72
+ }
73
+ hash.update(`manifest:${JSON.stringify(owned)}\n`);
74
+ let names = [];
75
+ try {
76
+ names = (await readdir(join(root, LOCAL_PROFILE_DIR))).filter((n) => n.endsWith('.md')).sort();
77
+ } catch (error) {
78
+ if (error.code !== 'ENOENT') throw error;
79
+ }
80
+ for (const name of names) {
81
+ const bytes = await readFile(join(root, LOCAL_PROFILE_DIR, name));
82
+ hash.update(`profile:${name}:${bytes.length}:`);
83
+ hash.update(bytes);
84
+ }
85
+ try {
86
+ const raw = await readFile(join(root, 'package.json'), 'utf8');
87
+ const scripts = JSON.parse(raw).scripts ?? {};
88
+ hash.update(`scripts:${JSON.stringify(scripts)}\n`);
89
+ } catch (error) {
90
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
91
+ }
92
+ return hash.digest('hex');
93
+ }
@@ -67,8 +67,12 @@ export async function manifestPath(root) {
67
67
  * real is the recorded SHA under `projection`.
68
68
  */
69
69
  export const MANIFEST_COMMENT = {
70
- $comment:
71
- `Written and maintained by simt-dev. Edit by hand only if you intend to take ownership of this file: \`${CLI} sync --yes\` regenerates the projected rules, and re-running \`${CLI} init\` refreshes the SHA without weakening a value you set here.`,
70
+ $comment: [
71
+ `Written and maintained by simt-dev. \`${CLI} sync --yes\` regenerates the projected rules.`,
72
+ `\`${CLI} init\` refreshes the SHA and keeps every key it does not write, so a value you add here survives a re-init.`,
73
+ `\`plans\` is such a key: it selects the tracker template and the directories \`${CLI} plan\` reads.`,
74
+ 'Omit it and the tracker template applies, which reads docs/plans and docs/reviews.',
75
+ ].join(' '),
72
76
  };
73
77
 
74
78
  /**
@@ -418,7 +422,7 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
418
422
  ? { ...planned.gates, commands: planned.commands }
419
423
  : planned.gates;
420
424
 
421
- return {
425
+ const owned = {
422
426
  ...MANIFEST_COMMENT,
423
427
  id: existing?.id ?? defaultId(root),
424
428
  stack: existing?.stack?.length ? existing.stack : stack,
@@ -459,6 +463,15 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
459
463
  // compliance (plan 0003 R02) — a mode chosen by default would be a bet the
460
464
  // repository never made. Declared, not generated, for the same reason as `budget`.
461
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 }),
462
475
  // ADR-020: which editors this repo's rules are projected into. Absent means the
463
476
  // global standard applies; an explicit empty list is a real choice meaning
464
477
  // AGENTS.md only. Recorded rather than inferred, because "which IDE does this repo
@@ -473,6 +486,25 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
473
486
  // adopted. See src/package-version.mjs.
474
487
  projection: { simtDevSha: sha ?? null, version: version ?? null },
475
488
  };
489
+ return withRepoKeys(existing, owned);
490
+ }
491
+
492
+ /**
493
+ * Keys this writer does not recognise belong to the repository.
494
+ *
495
+ * A re-init used to return only the object above, so anything else in the file
496
+ * disappeared. `plans` is the tracker overlay, and it is not the only key a
497
+ * repository might have added. Owned keys stay in the order the writer emits
498
+ * them; an unknown key is copied after them, in the order the file had it.
499
+ */
500
+ function withRepoKeys(existing, owned) {
501
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return owned;
502
+ const kept = {};
503
+ for (const key of Object.keys(existing)) {
504
+ if (Object.hasOwn(owned, key)) continue;
505
+ kept[key] = existing[key];
506
+ }
507
+ return { ...owned, ...kept };
476
508
  }
477
509
 
478
510
  /**
@@ -608,6 +640,24 @@ function defaultId(root) {
608
640
  */
609
641
  export const DEFAULT_BUDGET_BYTES = 30000;
610
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
+
611
661
  /**
612
662
  * Report a projection that carries more than the repository agreed to carry.
613
663
  *
@@ -644,6 +694,31 @@ export function budgetFinding(bytes, budget, mode = 'full') {
644
694
  };
645
695
  }
646
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
+
647
722
  export const GENERATED_KEYS = new Set(['$comment', 'projection']);
648
723
 
649
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) {