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
@@ -29,9 +29,15 @@ import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
29
29
  import { loadMdcRules, renderMdc } from './mdc.mjs';
30
30
  import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
31
31
  import { ruleTargets } from './targets.mjs';
32
- import { manifestPath, readManifest } from './manifest.mjs';
32
+ import { compose } from './markers.mjs';
33
+ import { declaredBudget, declaredShared, manifestPath, readManifest } from './manifest.mjs';
34
+ import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
35
+ import { packToBudget } from '../kernel/budget.mjs';
36
+ import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
37
+ import { checkDefect, foldChecks } from '../kernel/checks.mjs';
33
38
  import { BASELINES } from './baselines.mjs';
34
39
  import { CLI } from '../name.mjs';
40
+ import { installedSkills } from './disclosure.mjs';
35
41
  import { PACK_DIR, resolvePacks } from '../packs.mjs';
36
42
  import { discoverPackages } from '../monorepo.mjs';
37
43
 
@@ -408,12 +414,39 @@ async function ruleBodies(root, manifest, flags) {
408
414
  return { resolved, bodies, problems, notes, skipped };
409
415
  }
410
416
 
417
+ /**
418
+ * The command card for a repository and the evidence its markers may be proved by.
419
+ *
420
+ * Both the region and the package regions need the proofs, and they must be the same set:
421
+ * a rule dropped at the root and kept in a package would be one rule treated as two. The
422
+ * card comes back alongside because the caller that needs the proofs also needs the card's
423
+ * bytes, and building it twice would read the manifest twice for no second answer.
424
+ */
425
+ async function inferableEvidence(root, { stack, facts, manifest }) {
426
+ const card = await commandCard({ root, manifest, stack, facts });
427
+ return { card, proofs: inferableProofs({ facts, stack, card, manifest }) };
428
+ }
429
+
411
430
  /**
412
431
  * The one place the desired region is computed. `plan` (which writes) and
413
432
  * `checkTarget` (which gates) both go through this, so they cannot disagree
414
433
  * about what the region should contain — a divergence here would report drift
415
434
  * where none exists, and CI would flake on a clean tree.
416
435
  */
436
+ /**
437
+ * Is this rule one this package ships, rather than one the repository added?
438
+ *
439
+ * Two lists, because two sources are read: profiles carry the `origin` they were loaded
440
+ * from (`central` for `rules/`, `pack`, `local`), while the canonical `.mdc` rules are read
441
+ * out of the package and have no origin at all — only a `rules/mdc/…` source. Asking
442
+ * `origin === 'central'` of both silently drops every process rule from the workspace's
443
+ * shared file, which is how the first version of this predicate lost three of five rules
444
+ * in a two-repository measurement without failing anything.
445
+ */
446
+ function toolRule(rule) {
447
+ return rule.origin !== undefined ? rule.origin === 'central' : rule.source.startsWith('rules/');
448
+ }
449
+
417
450
  export async function desiredRegion(root, flags) {
418
451
  const { stack, facts, manifest } = await fingerprint(root);
419
452
  const all = await loadRules(root);
@@ -469,50 +502,324 @@ export async function desiredRegion(root, flags) {
469
502
  if (mode === 'kernel' && (r.error !== null || !KERNEL_CLASSES.has(r.priority))) deferred.push(r);
470
503
  else packed.push(r);
471
504
  }
472
- let body = render(packed);
473
- let sources = packed.map((r) => r.source);
474
- const folded = [];
475
-
476
- // The pointer. A reader who opens AGENTS.md and does not find a profile they know is
477
- // there will look for it, and the honest answer is where it moved: the file a scoping
478
- // surface loads it from, and the files it now matches. Without the line the region's
479
- // source list is quietly short one entry, which reads as a rule that was deleted.
480
- if (scoped.length > 0 && narrowing) {
481
- const lines = scoped.map(
482
- (r) =>
483
- `- ${sourceTitle(r, 'profile')} — loads only when a matching file is in play (${r.scope.join(', ')}): ${scopedPaths(r, resolved).join(', ')}`,
505
+ // The command card leads the region. The exact commands for this repository are the
506
+ // bytes a reader cannot infer from rules that are identical everywhere (plan 0003 R10),
507
+ // so they go first — above even the core rules. It is derived from committed evidence
508
+ // only, in one place, so `plan` and `checkTarget` cannot disagree about it and two
509
+ // machines render it byte-identically.
510
+ const { card, proofs } = await inferableEvidence(root, { stack, facts, manifest });
511
+ if (card.dropped.length > 0) {
512
+ // Reported where every other projection note is reported. A card that lost a command
513
+ // in silence is a card a reader cannot tell is incomplete.
514
+ notes.push(
515
+ `command card: dropped ${card.dropped.join(', ')} to stay within ${CARD_LIMIT_BYTES} bytes`,
484
516
  );
485
- body += `\n\n## Scoped rules\n\n${lines.join('\n')}`;
486
- for (const r of scoped) sources.push(r.source);
487
- }
488
-
489
- if (deferred.length > 0) {
490
- // The pointer is the same shape as the scoped one above, and for the same reason:
491
- // a reader who opens AGENTS.md and does not find a rule they know is there will
492
- // look for it. What it costs to find it has to be one command, named per rule.
493
- const lines = [...deferred]
494
- .sort(byPriorityThenId)
495
- .map(
496
- (r) =>
497
- `- ${sourceTitle(r, 'profile')} — ${r.priority ?? 'unclassified'}, deferred in kernel mode. Show it with: ${CLI} rules show ${r.id}`,
498
- );
499
- body += `\n\n## Deferred rules\n\n${lines.join('\n')}`;
500
- for (const r of deferred) sources.push(r.source);
501
517
  }
518
+ // Inferability (R09). A marked line leaves when the repository proves the evidence it
519
+ // names. `shipped` is a separate list from `packed` because they are separate decisions:
520
+ // the kernel is about which classes cost a reader nothing to skip, and this is about
521
+ // which lines the reader already knows.
522
+ const inferable = [];
523
+ const shipped = [];
524
+ let enforced = 0;
525
+ for (const r of packed) {
526
+ const filtered = filterInferable(r.body, proofs);
527
+ const folded = foldChecks(filtered.body);
528
+ enforced += folded.enforced.length;
529
+ if (!filtered.changed && !folded.changed) {
530
+ shipped.push(r);
531
+ continue;
532
+ }
533
+ for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
534
+ shipped.push({ ...r, body: folded.body });
535
+ }
536
+ if (enforced > 0) {
537
+ notes.push(
538
+ `enforced: ${enforced} line(s) left the region because a checker runs them`,
539
+ );
540
+ }
541
+ if (inferable.length > 0) {
542
+ // Never silent, and never a heading. A line that left with no trace repeats the
543
+ // mistake ADR-073 rejected for deferred rules, and a `## Dropped` section spends region
544
+ // bytes on lines the reader is being told not to read. A count they can act on, plus —
545
+ // with `--explain` — the evidence behind each one, is the third option.
546
+ notes.push(
547
+ `inferability: dropped ${inferable.length} line(s) this repository already proves; ${CLI} sync --explain names each one and the evidence behind it`,
548
+ );
549
+ if (flags?.explain === true) {
550
+ for (const x of inferable) {
551
+ notes.push(`inferable: ${x.rule} dropped "${x.text}" — proved by ${x.expr}`);
552
+ }
553
+ }
554
+ }
555
+ const folded = foldsRules(resolved) ? bodies.map(({ rule }) => rule.id) : [];
556
+
557
+ // The workspace-shared rules this repository declared (ADR-084).
558
+ //
559
+ // Read from the manifest and never from the reader's home directory, because this
560
+ // function is what `sync` and `check` both call: a region that depended on what
561
+ // somebody had installed would render differently on a machine where they had not,
562
+ // and CI would report drift on a tree `sync` had just written. The ids travel in the
563
+ // manifest so the answer to "what leaves this region" is a fact about the repository.
564
+ const declared = declaredShared(manifest);
565
+ const carriedIds = new Set(declared.level === null ? [] : [...declared.rules]);
566
+
567
+ // Every rule the workspace carries, as the rule objects the index below names. Both
568
+ // lists, because a surface that folds its rules into the primary document carries the
569
+ // canonical process bodies in that document too — and a reader who cannot find
570
+ // `slice-commit` anywhere near their repository will look for it in AGENTS.md.
571
+ const carried = [
572
+ ...shipped.filter((r) => carriedIds.has(r.id)).map((rule) => ({ rule, kind: 'profile' })),
573
+ ...bodies.filter(({ rule }) => carriedIds.has(rule.id)).map(({ rule }) => ({ rule, kind: 'process' })),
574
+ ];
575
+
576
+ // The rules this root would carry in its region, with the bytes it would carry them
577
+ // in — the input ADR-084's workspace intersection compares.
578
+ //
579
+ // Computed from `shipped` rather than from what the region ends up carrying, and that
580
+ // is the whole reason it is computed at all: a rule already declared shared must still
581
+ // appear here, or a second `shared install` would intersect against a list that no
582
+ // longer contained it and shrink the shared file every time anybody ran the command.
583
+ // The declaration is an input to the region; it is not an input to what is shareable.
584
+ const shareable = [
585
+ // Only the rules this package ships. A repository's own profile, or a pack it
586
+ // installed, is policy somebody chose to write: carrying it into a shared home file
587
+ // would mean editing that file to change a repository's rules, and the copy would
588
+ // reach every other repository in the estate that declared the same id. A rule
589
+ // somebody can override must stay somewhere they own.
590
+ ...shipped
591
+ .filter((r) => r.error === null && toolRule(r) && typeof r.id === 'string' && r.id !== '')
592
+ .map((r) => ({
593
+ id: r.id,
594
+ source: r.source,
595
+ priority: r.priority ?? null,
596
+ rendered: frameSource(r, stripFrontmatter(r.body).trim(), 'profile'),
597
+ })),
598
+ // The folded process bodies, and only while they are folded. A surface that writes
599
+ // one file per rule expects that file at a path inside the repository, so a
600
+ // user-level copy cannot stand in for it — but when the surface folds the whole region
601
+ // into the primary document, those bytes are as repeated across a workspace as the
602
+ // profiles are, and `tokens --repos` named them as the last duplicate block in a
603
+ // two-repository measurement.
604
+ ...bodies
605
+ .filter(({ rule }) => folded.includes(rule.id) && toolRule(rule))
606
+ .map(({ rule, body }) => ({
607
+ id: rule.id,
608
+ source: rule.source,
609
+ priority: rule.priority ?? null,
610
+ rendered: frameSource(rule, body, 'process'),
611
+ })),
612
+ ].filter((r) => r.rendered !== '');
502
613
 
503
- if (foldsRules(resolved)) {
504
- for (const { rule, body: ruleBody } of bodies) {
505
- // `## Process:`, not `### id`. The body already uses `##`, and a third-level
506
- // heading in front of a second-level one is the break a reader cannot repair.
507
- body += `\n\n${frameSource(rule, ruleBody, 'process')}`;
508
- sources.push(rule.source);
509
- folded.push(rule.id);
614
+ // The selected rules that are not shareable, with the reason. `shared plan` reporting
615
+ // only what it carries would imply every profile in the workspace had been accounted
616
+ // for, and a reader whose own house rule is missing from the list would conclude it
617
+ // moved — so the names it did not take, and why, are data rather than an omission.
618
+ const shareableIds = new Set(shareable.map((r) => r.id));
619
+ const excluded = selected
620
+ .filter((r) => typeof r.id === 'string' && r.id !== '' && !shareableIds.has(r.id))
621
+ .map((r) => ({
622
+ id: r.id,
623
+ why: r.error !== null ? 'error' : toolRule(r) ? 'empty' : 'policy',
624
+ }));
625
+
626
+ // The budget, and the bytes held back for what loads beside the region.
627
+ //
628
+ // Read from the manifest rather than from the plan's spelling of it. Plan 0003 R08
629
+ // wrote `projection.budget.bytes`, and ADR-072 moved the key to the top level because
630
+ // `projection` is a generated key `upgrade` replaces wholesale - a budget that reset
631
+ // itself on upgrade would reset the reader's context size without anyone asking. The
632
+ // plan is not edited: the key it names is wrong, and a ruling that supersedes it is
633
+ // the record of why.
634
+ const budget = declaredBudget(manifest);
635
+ // Names and triggers only. Read once, outside the packer, because a skill does not
636
+ // change as rules are cut and reading its body here would put the cost back into the
637
+ // region the index exists to keep it out of (ADR-085).
638
+ const skills = await installedSkills(root);
639
+
640
+ // The digest is needed before the region can be weighed rather than after, because the
641
+ // provenance header is part of what lands in the file. `provenanceDigest` is a pure
642
+ // function of the bytes this package ships, so computing it once and passing it down is
643
+ // the same value the tail of this function was getting on its own.
644
+ const sha = await provenanceDigest();
645
+
646
+ /**
647
+ * The region, assembled from whatever survived the budget (ADR-083).
648
+ *
649
+ * One function, called once for the whole projection and again for each attempt the
650
+ * packer makes, so a cut region and an uncut one are the same document by construction
651
+ * rather than by two functions agreeing. The card, the inferability notes, the folded
652
+ * process bodies and the disclosure index are not candidates for a cut: a budget is a
653
+ * decision about rules, and dropping the index would hide the rules the cut removed.
654
+ */
655
+ const assemble = (cut) => {
656
+ // A rule the workspace carries once leaves the region, and so does a rule the packer
657
+ // could not afford. Both are exclusions from the same list, so the section below can
658
+ // name all three reasons — which is the only reason the section exists: a reader who
659
+ // opens AGENTS.md and does not find a rule they know is there will look for it.
660
+ const kept = shipped.filter((r) => !cut.has(r.id) && !carriedIds.has(r.id));
661
+ // Stable bytes first (ADR-086). Core, then the profiles this package ships, then
662
+ // everything this repository added — the card, its own profiles, the process
663
+ // bodies. A line the repository edits then changes only the tail, and the prefix
664
+ // a reader has cached stays byte-identical. The card used to lead (ADR-077 ruling
665
+ // 1); it now leads the tail, because a command this repository owns is the thing
666
+ // an edit is most likely to move.
667
+ const stable = kept.filter((r) => toolRule(r)).sort(stableOrder);
668
+ const repoRules = kept.filter((r) => !toolRule(r)).sort((a, b) => String(a.id).localeCompare(String(b.id)));
669
+ let text = render(stable);
670
+ if (card.text) text += `${text === '' ? '' : '\n\n'}${card.text}`;
671
+ const repoText = render(repoRules);
672
+ if (repoText) text += `\n\n${repoText}`;
673
+ const sources = [...stable, ...repoRules].map((r) => r.source);
674
+
675
+ // Every reason a rule is missing lands in this one section, and each line says which
676
+ // it was. A rule deferred by its class, a rule cut to reach a byte count and a rule
677
+ // the workspace carries once are three different decisions, but they are the same fact
678
+ // to a reader — it is not in this file — and three headings would spend region bytes
679
+ // on structure nobody can act on. The line names the command that shows the rule,
680
+ // because for all three the remedy is the same single read.
681
+ //
682
+ // The label is the rule's own frame title, which is why each entry carries the kind it
683
+ // is rendered as: a canonical rule that the surface folds in is `Process: <id>` in the
684
+ // shared file, and an index that calls it `Stack:` sends the reader looking for the
685
+ // wrong thing.
686
+ const omitted = [
687
+ ...deferred.map((r) => ({ rule: r, kind: 'profile', why: `${r.priority ?? 'unclassified'}, deferred in kernel mode` })),
688
+ ...shipped
689
+ .filter((r) => cut.has(r.id))
690
+ .map((r) => ({
691
+ rule: r,
692
+ kind: 'profile',
693
+ why: `${r.priority ?? 'unclassified'}, cut to fit the declared budget of ${budget} bytes`,
694
+ })),
695
+ ...carried.map(({ rule, kind }) => ({
696
+ rule,
697
+ kind,
698
+ // No path here, deliberately. This text is rendered by `sync` in CI, where the
699
+ // reader's home directory is somebody else's, and a path is the one thing in a
700
+ // remedy that cannot be true on both. The remedy is a command that works on the
701
+ // machine the reader is on.
702
+ why: `${rule.priority ?? 'unclassified'}, carried once per workspace in your reader's user-level rules file instead; if your reader has no copy, run: ${CLI} shared install`,
703
+ })),
704
+ ];
705
+ if (omitted.length > 0) {
706
+ // The pointer is the same shape as the scoped one above, and for the same reason:
707
+ // a reader who opens AGENTS.md and does not find a rule they know is there will
708
+ // look for it. What it costs to find it has to be one command, named per rule.
709
+ const lines = omitted
710
+ .sort((a, b) => byPriorityThenId(a.rule, b.rule))
711
+ .map(
712
+ ({ rule, kind, why }) =>
713
+ `- ${sourceTitle(rule, kind)} — ${why}. Show it with: ${CLI} rules show ${rule.id}`,
714
+ );
715
+ // `## Rules not in this region`, not `## Deferred rules`. The heading was written
716
+ // when there was one reason to be here (ADR-073) and it is region text a reader
717
+ // reads, so a heading that names one of three reasons misdescribes the two others
718
+ // (ADR-084).
719
+ text += `\n\n## Rules not in this region\n\n${lines.join('\n')}`;
720
+ for (const { rule } of omitted) sources.push(rule.source);
721
+ }
722
+
723
+ if (folded.length > 0) {
724
+ for (const { rule, body: ruleBody } of bodies) {
725
+ // A body the workspace carries once is not folded in here either. The heading is
726
+ // about how the surface loads the region, so it stays; the rule under it does not,
727
+ // and a region that carried the same bytes twice is the redundancy ADR-084 exists
728
+ // to remove — reintroduced by the feature that removes it.
729
+ if (carriedIds.has(rule.id)) continue;
730
+ // `## Process:`, not `### id`. The body already uses `##`, and a third-level
731
+ // heading in front of a second-level one is the break a reader cannot repair.
732
+ text += `\n\n${frameSource(rule, ruleBody, 'process')}`;
733
+ sources.push(rule.source);
734
+ }
735
+ }
736
+
737
+ // Last, because this is the index of what the file deliberately does not contain
738
+ // (ADR-085). A scoped profile keeps the pointer ADR-071 requires — its patterns and
739
+ // the files it moved to — and a skill keeps its name and the description that says
740
+ // when to open it. The body of either is the thing that was moved out.
741
+ const disclosure = [];
742
+ if (scoped.length > 0 && narrowing) {
743
+ for (const r of scoped) {
744
+ disclosure.push(
745
+ `- ${sourceTitle(r, 'profile')} — loads only when a matching file is in play (${r.scope.join(', ')}): ${scopedPaths(r, resolved).join(', ')}`,
746
+ );
747
+ sources.push(r.source);
748
+ }
749
+ }
750
+ const skillNames = new Map();
751
+ for (const skill of skills) skillNames.set(skill.name, (skillNames.get(skill.name) ?? 0) + 1);
752
+ for (const skill of skills) {
753
+ const label = skillNames.get(skill.name) > 1 ? `${skill.name} (${skill.dir})` : skill.name;
754
+ disclosure.push(
755
+ skill.trigger === ''
756
+ ? `- ${label} — no description, so nothing says when to open it. Show it with: ${CLI} skills show ${skill.name}`
757
+ : `- ${label} — ${skill.trigger}`,
758
+ );
759
+ }
760
+ if (disclosure.length > 0) text += `\n\n## Disclosure\n\n${disclosure.join('\n')}`;
761
+
762
+ return { body: text, sources };
763
+ };
764
+
765
+ // What the region will weigh in the file it lands in, measured the way the meter
766
+ // measures it: the composed region text, markers and provenance header included. One
767
+ // byte conservative, because the file's own trailing newline falls outside the span —
768
+ // and conservative in that direction is the only direction a ceiling can afford.
769
+ const regionBytes = (assembled) =>
770
+ Buffer.byteLength(compose(assembled.body, { sha, sources: assembled.sources }));
771
+
772
+ let assembled = assemble(new Set());
773
+ let packing = null;
774
+ if (mode === 'kernel') {
775
+ const reserve = await budgetReserve(root, resolved, bodies, sha);
776
+ const result = packToBudget({
777
+ // Least valuable first: the comparator the deferred section already uses, reversed.
778
+ // Class decides and the id breaks the tie, so the cut order is a fact about the
779
+ // repository rather than about the order the filesystem listed files in.
780
+ //
781
+ // The workspace-shared rules are not candidates, and that is not a rounding: cutting
782
+ // one cannot make the region smaller by more than the index line it adds to the
783
+ // section below, because the rule is carried once per workspace whatever this region
784
+ // does. A packer that spent its budget on them would report a saving it did not make.
785
+ candidates: shipped
786
+ .filter((r) => !carriedIds.has(r.id))
787
+ .sort((a, b) => -byPriorityThenId(a, b)),
788
+ ceiling: budget - reserve,
789
+ costs: (cut) => regionBytes(assemble(cut)),
790
+ });
791
+ packing = { ...result, bytes: budget, ceiling: budget - reserve, reserve };
792
+ if (result.cut.length > 0) assembled = assemble(new Set(result.cut));
793
+ if (result.cut.length > 0) {
794
+ notes.push(
795
+ `budget: cut ${result.cut.length} kernel rule(s) — ${result.cut.join(', ')} — from ${result.before} to ${result.after} bytes against a declared budget of ${budget}, with ${reserve} reserved for the generated files that load beside the region`,
796
+ );
797
+ }
798
+ if (result.floorHeld) {
799
+ // The one case the packer cannot fix, said where the reader will see it. `hard` is
800
+ // the floor, so the bytes still over the line are this repository's own hard
801
+ // constraints and the only levers left are its own: a larger budget, or a rule that
802
+ // is not as hard as it is declared. ADR-072's kernel finding says both; this says
803
+ // they were tried first.
804
+ notes.push(
805
+ `budget: still ${result.after} bytes after every rule outside the hard class was cut, against a ceiling of ${budget - reserve}; a hard constraint is not cut to reach a byte count, so the kernel itself is over the line`,
806
+ );
807
+ }
808
+ if (result.floorHeld && reserve >= budget) {
809
+ // A ceiling of zero or less is arithmetically true and useless to read: it says the
810
+ // region is allowed no bytes, without saying that the files loading beside it have
811
+ // already spent the whole budget. One number in that manifest is the only lever, and
812
+ // a reader left to infer it from a subtraction is a reader who will demote a hard
813
+ // rule to fix a budget that was set below the files it covers.
814
+ notes.push(
815
+ `budget: the ${reserve} bytes reserved for the generated files that load beside the region already reach the ${budget} declared, so no cut order meets this budget and the region is cut as far as the hard class allows`,
816
+ );
510
817
  }
511
818
  }
512
819
 
513
820
  const meta = {
514
- sha: await provenanceDigest(),
515
- sources,
821
+ sha,
822
+ sources: assembled.sources,
516
823
  };
517
824
  return {
518
825
  stack,
@@ -523,6 +830,39 @@ export async function desiredRegion(root, flags) {
523
830
  folded,
524
831
  mode,
525
832
  deferred: deferred.map((r) => r.id),
833
+ // What the workspace carries once per reader instead of here (ADR-084), as ids. The
834
+ // declaration is what `check` and `sync` both read to decide the region, so the ids
835
+ // that actually left are reported for the same reason the budget's cuts are: a rule
836
+ // that disappeared needs a name in the output, or a reader looks for it in vain.
837
+ carried: carried.map(({ rule }) => rule.id),
838
+ // What this root could contribute to a workspace's shared file, with the bytes it
839
+ // would contribute. Read by `simt shared`, never by the projection — which is why
840
+ // computing it costs a frame per rule and buys nothing on `sync`.
841
+ shareable,
842
+ // What it selected and cannot contribute, with the reason. Reported so that a rule
843
+ // staying in the region is always a decision somebody can see rather than an absence.
844
+ excluded,
845
+ // What the budget cost this projection (ADR-083), or null in `full` mode where no
846
+ // ceiling is applied because nothing is cut. The finding and `simt tokens` both read
847
+ // this rather than re-deriving it, so a cut cannot be reported one way and measured
848
+ // another.
849
+ budget: packing === null
850
+ ? null
851
+ : {
852
+ bytes: packing.bytes,
853
+ ceiling: packing.ceiling,
854
+ reserve: packing.reserve,
855
+ before: packing.before,
856
+ after: packing.after,
857
+ floorHeld: packing.floorHeld,
858
+ cuts: shipped
859
+ .filter((r) => packing.cut.includes(r.id))
860
+ .map((r) => ({ id: r.id, priority: r.priority ?? 'unclassified' })),
861
+ },
862
+ // The marked lines that left, each with the evidence that dropped it. The region is
863
+ // written without them; `--explain` is what reads this list, so a removal is reportable
864
+ // without spending region bytes and without a second computation to find it.
865
+ inferable,
526
866
  // The profiles that left the region, and the surfaces that accepted them as files.
527
867
  // `plan` writes them and `checkTarget` gates them through this list, so a projected
528
868
  // file and a drifted one are decided by the same computation.
@@ -533,11 +873,57 @@ export async function desiredRegion(root, flags) {
533
873
  problems,
534
874
  fallback,
535
875
  notes,
536
- body,
876
+ body: assembled.body,
537
877
  meta,
538
878
  };
539
879
  }
540
880
 
881
+ /**
882
+ * The bytes held back from the region's own ceiling for the always-on files that load
883
+ * beside it.
884
+ *
885
+ * The budget is one number for a repository, not for one file, so packing the region
886
+ * against the whole number would ship a projection that fits on its own and pushes the
887
+ * total over the line the moment its rule files are written. Both terms below are lower
888
+ * bounds on what that costs, and the larger is taken because the failure each one covers
889
+ * is the one that must not happen: under-reserving reports compliance for a projection
890
+ * that is over budget.
891
+ *
892
+ * The region's own span is deliberately not in either. It is the one number that moves
893
+ * when this projection is packed, and a ceiling that depended on the thing it governs
894
+ * would rewrite the region on the next run and report drift on a tree `sync` had just
895
+ * written - the step-6 defect, rebuilt one layer up.
896
+ *
897
+ * @param {string} root
898
+ * @param {object} resolved the surfaces this projection writes for
899
+ * @param {{rule: object, body: string}[]} bodies the rule bodies being projected
900
+ * @param {string} sha the provenance digest those files will be stamped with
901
+ * @returns {Promise<number>}
902
+ */
903
+ async function budgetReserve(root, resolved, bodies, sha) {
904
+ // Imported here rather than at the top of the module: `measure/context.mjs` reaches back
905
+ // into `projection/project.mjs` for the read-limit findings, so a static import from
906
+ // `rules.mjs` closes a loop through three modules that all have to survive being loaded
907
+ // in any order. The loop between the other two already exists and is load-bearing in
908
+ // neither direction; this one is walked only when a kernel is being packed.
909
+ const { measureRoot } = await import('../measure/context.mjs');
910
+ const measured = await measureRoot(root);
911
+ const region = measured.documents.find((d) => d.channel === 'region');
912
+ const installed = Math.max(0, measured.generatedBytes - (region?.generatedBytes ?? 0));
913
+
914
+ // What this projection is about to write, for a repository that has not written it yet.
915
+ // `ruleTargets` is the one place a rule file is turned into bytes, so the number comes
916
+ // from there rather than from a second measurement of the same file — and its notes go
917
+ // nowhere, because `mdcTargets` reports the same ones for the same files in the same run.
918
+ let planned = 0;
919
+ for (const { rule, body } of bodies) {
920
+ for (const target of (await ruleTargets(root, rule, body, sha, resolved, [])).targets) {
921
+ planned += target.bytes;
922
+ }
923
+ }
924
+ return Math.max(installed, planned);
925
+ }
926
+
541
927
  /**
542
928
  * Every per-rule file a scoped profile is written to, as paths a reader can open.
543
929
  *
@@ -643,6 +1029,7 @@ export async function loadRules(root = null) {
643
1029
  for (const item of collected) {
644
1030
  const text = await readFile(join(item.dir, item.file), 'utf8');
645
1031
  const meta = parseFrontmatter(text);
1032
+ const body = stripFrontmatter(text);
646
1033
  out.push({
647
1034
  file: item.file,
648
1035
  origin: item.origin,
@@ -654,8 +1041,12 @@ export async function loadRules(root = null) {
654
1041
  scope: Array.isArray(meta['simt-scope']) ? meta['simt-scope'] : null,
655
1042
  priority: meta['simt-priority'] ?? null,
656
1043
  bytes: Buffer.byteLength(text),
657
- body: stripFrontmatter(text),
658
- error: describeDefect(meta),
1044
+ body,
1045
+ // A marker naming evidence the tool cannot check is a broken rule like any other, so
1046
+ // it travels the field that already reaches `check` and `rules list`. The
1047
+ // frontmatter defect wins, because a rule that cannot say what it applies to cannot
1048
+ // be reasoned about at all.
1049
+ error: describeDefect(meta) ?? inferableDefect(body) ?? checkDefect(body),
659
1050
  });
660
1051
  }
661
1052
  return out;
@@ -691,12 +1082,22 @@ export async function packageRegions(root, flags, rootProfiles = null) {
691
1082
 
692
1083
  const rules = await loadRules(root);
693
1084
  const sha = await provenanceDigest();
1085
+ // The ROOT's proofs, not the package's. The rule set is the root's, so which lines it
1086
+ // already proves is a fact about the repository rather than about the directory a file
1087
+ // lands in — filtering a package by the package's own facts would keep a rule here and
1088
+ // drop it one directory up, which is the same rule treated as two.
1089
+ const { proofs } = await inferableEvidence(root, await fingerprint(root));
694
1090
  const regions = [];
695
1091
  for (const pkg of packages) {
696
1092
  const fp = await fingerprint(join(root, pkg.path));
697
1093
  const { selected } = select(rules, fp.stack ?? [], fp.facts ?? []);
698
1094
  const delta = selected.filter((r) => !shared.includes(r.id));
699
1095
  if (delta.length === 0) continue;
1096
+ const shipped = delta.map((r) => {
1097
+ const filtered = filterInferable(r.body, proofs);
1098
+ const folded = foldChecks(filtered.body);
1099
+ return filtered.changed || folded.changed ? { ...r, body: folded.body } : r;
1100
+ });
700
1101
  regions.push({
701
1102
  path: pkg.path,
702
1103
  ecosystem: pkg.ecosystem,
@@ -706,7 +1107,7 @@ export async function packageRegions(root, flags, rootProfiles = null) {
706
1107
  // One sentence, because the file's whole purpose is to be found by a reader
707
1108
  // who does not know it exists: without it, a nested region reads as a
708
1109
  // complete set of rules and the shared half looks forgotten.
709
- body: `The rules this package selects on its own. Everything the repository shares with its other packages is in the region at the root.\n\n${render(delta)}`,
1110
+ body: `The rules this package selects on its own. Everything the repository shares with its other packages is in the region at the root.\n\n${render(shipped)}`,
710
1111
  meta: { sha, sources: delta.map((r) => r.source) },
711
1112
  });
712
1113
  }
@@ -835,7 +1236,15 @@ async function projectionMode(root) {
835
1236
  * would move when a file is added to the directory, and a projection that reorders
836
1237
  * itself on an unrelated commit is a projection nobody can diff (ADR-013).
837
1238
  */
838
- function byPriorityThenId(a, b) {
1239
+ /** Core, then the remaining shipped profiles by id. Id, not load order: a directory
1240
+ * listing is not a fact about the rules, and two machines must not disagree about it. */
1241
+ function stableOrder(a, b) {
1242
+ if (a.id === 'core') return b.id === 'core' ? 0 : -1;
1243
+ if (b.id === 'core') return 1;
1244
+ return String(a.id).localeCompare(String(b.id));
1245
+ }
1246
+
1247
+ export function byPriorityThenId(a, b) {
839
1248
  const classA = PRIORITY_CLASSES.indexOf(a.priority ?? 'style');
840
1249
  const classB = PRIORITY_CLASSES.indexOf(b.priority ?? 'style');
841
1250
  if (classA !== classB) return classA - classB;