simt-dev 0.2.1 → 0.3.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 (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -27,10 +27,15 @@ import { dirname, join, resolve } from 'node:path';
27
27
 
28
28
  import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
29
29
  import { loadMdcRules, renderMdc } from './mdc.mjs';
30
- import { foldsRules, frontmatterBlock, resolveSurfaces } from './surfaces.mjs';
30
+ import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
31
31
  import { ruleTargets } from './targets.mjs';
32
- import { manifestPath } from './manifest.mjs';
32
+ import { manifestPath, readManifest } from './manifest.mjs';
33
+ import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
34
+ import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
33
35
  import { BASELINES } from './baselines.mjs';
36
+ import { CLI } from '../name.mjs';
37
+ import { PACK_DIR, resolvePacks } from '../packs.mjs';
38
+ import { discoverPackages } from '../monorepo.mjs';
34
39
 
35
40
  const HERE = dirname(fileURLToPath(import.meta.url));
36
41
  export const ROOT = resolve(HERE, '..', '..');
@@ -123,12 +128,37 @@ async function walk(dir, prefix = '') {
123
128
  /** Repo-local profile directory, relative to a repo root (ADR-013 section 2). */
124
129
  export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
125
130
 
131
+ /**
132
+ * Which origin outranks which when two profiles claim one `simt-id`.
133
+ *
134
+ * A pack is the repository's shared policy and the built-in set is the tool's
135
+ * opinion, so a repository can replace either and a pack can replace what the
136
+ * tool ships. Read in one place because the answer to "whose rule is this?" is
137
+ * asked on every rule of every projection, and a precedence that lives in two
138
+ * places is a precedence that will disagree with itself.
139
+ */
140
+ const ORIGIN_RANK = { central: 0, pack: 1, local: 2 };
141
+
126
142
  /** Every key a stack profile or core rule may declare is this tool's own, so every
127
143
  * one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
128
144
  * as an editor's instruction — Cursor, Copilot and Antigravity all parse a
129
145
  * frontmatter block the same way — and would be silently inert. */
130
146
  /** Frontmatter keys a rule profile declares. See {@link KNOWN_KEYS} on why exported. */
131
- export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
147
+ export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback', 'simt-scope', 'simt-priority']);
148
+
149
+ /**
150
+ * Priority classes, packed in this order (plan 0003 R07).
151
+ *
152
+ * A class is not a severity, it is a promise about what a reader loses by not having
153
+ * the line. A hard constraint is one whose absence produces a defect; a command is one
154
+ * the reader cannot guess; a convention is right to follow and harmless to miss; a style
155
+ * is a preference. Anything after `command` is the first thing a kernel drops, and that
156
+ * ordering is the whole content of the mode — everything else about it is bookkeeping.
157
+ */
158
+ export const PRIORITY_CLASSES = ['hard', 'command', 'convention', 'style'];
159
+
160
+ /** The classes a kernel keeps. `full` mode keeps all four and is the default. */
161
+ export const KERNEL_CLASSES = new Set(['hard', 'command']);
132
162
 
133
163
  /** Report the first thing wrong with a profile's frontmatter, in the order a reader
134
164
  * fixes it: no id to select it by, then a key that will not be read, then a tag the
@@ -147,14 +177,33 @@ function describeDefect(meta) {
147
177
  const prefixed = `simt-${unknown.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
148
178
  return PROFILE_KEYS.has(prefixed)
149
179
  ? `key "${unknown}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
150
- : `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback`;
180
+ : `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback, simt-scope`;
151
181
  }
152
182
  const applies = meta['simt-applies'];
153
183
  if (!Array.isArray(applies)) return null;
154
184
  const undeclared = applies.find((tag) => !KNOWN_TAGS.has(tag));
155
- return undeclared === undefined
156
- ? null
157
- : `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
185
+ if (undeclared !== undefined) {
186
+ return `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
187
+ }
188
+ const priority = meta['simt-priority'];
189
+ if (priority !== undefined && !PRIORITY_CLASSES.includes(priority)) {
190
+ return `simt-priority is "${priority}"; the classes are ${PRIORITY_CLASSES.join(', ')}, in the order a kernel packs them`;
191
+ }
192
+ const scope = meta['simt-scope'];
193
+ if (scope === undefined) return null;
194
+ // A scope that matches the whole repository is the eager profile wearing a costume:
195
+ // the reader is told the rule narrows, and the rule loads on every file. `**` and
196
+ // `**/*` are the two spellings of that, and both are the default a surface applies
197
+ // when it is given no pattern at all.
198
+ const CATCH_ALL = new Set(['**', '**/*', '*']);
199
+ if (scope.length === 0) {
200
+ return 'simt-scope is empty; a profile with no pattern would load as eagerly as one with no simt-scope at all';
201
+ }
202
+ const bad = scope.find((glob) => typeof glob !== 'string' || glob === '' || CATCH_ALL.has(glob));
203
+ if (bad !== undefined) {
204
+ return `simt-scope needs at least one pattern that narrows; "${bad}" matches every file, so the profile would load exactly as eagerly as one with no simt-scope`;
205
+ }
206
+ return null;
158
207
  }
159
208
 
160
209
  /**
@@ -361,6 +410,19 @@ async function ruleBodies(root, manifest, flags) {
361
410
  return { resolved, bodies, problems, notes, skipped };
362
411
  }
363
412
 
413
+ /**
414
+ * The command card for a repository and the evidence its markers may be proved by.
415
+ *
416
+ * Both the region and the package regions need the proofs, and they must be the same set:
417
+ * a rule dropped at the root and kept in a package would be one rule treated as two. The
418
+ * card comes back alongside because the caller that needs the proofs also needs the card's
419
+ * bytes, and building it twice would read the manifest twice for no second answer.
420
+ */
421
+ async function inferableEvidence(root, { stack, facts, manifest }) {
422
+ const card = await commandCard({ root, manifest, stack, facts });
423
+ return { card, proofs: inferableProofs({ facts, stack, card, manifest }) };
424
+ }
425
+
364
426
  /**
365
427
  * The one place the desired region is computed. `plan` (which writes) and
366
428
  * `checkTarget` (which gates) both go through this, so they cannot disagree
@@ -371,21 +433,130 @@ export async function desiredRegion(root, flags) {
371
433
  const { stack, facts, manifest } = await fingerprint(root);
372
434
  const all = await loadRules(root);
373
435
  const { selected, fallback, notes } = select(all, stack, facts);
374
-
375
- let body = render(selected);
376
- let sources = selected.map((r) => r.source);
377
- const folded = [];
378
-
379
- // When every selected surface carries its rules inside the primary document, the
380
- // per-rule bodies belong in the region too — otherwise a repo on the standard
381
- // surface receives AGENTS.md and none of the always-on process rules, because
382
- // those had nowhere else to live. One file, one region, one place a reader opens.
383
436
  const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
384
437
  root,
385
438
  manifest,
386
439
  flags ?? {},
387
440
  );
388
441
  notes.push(...ruleNotes);
442
+
443
+ // A profile that declares a scope leaves the region only when every selected surface
444
+ // can load it narrowly. The alternative is a profile written only as a scoped file for
445
+ // a reader whose surface cannot scope — a rule nobody loads, which is worse than one
446
+ // that loads eagerly (ADR-071). A surface that cannot scope it therefore keeps the
447
+ // profile in the region and says so, rather than letting the region quietly shrink.
448
+ const narrowing = resolved.surfaces.every((s) => canScope(s));
449
+ const scoped = [];
450
+ const inRegion = [];
451
+ for (const r of selected) {
452
+ const declares = Array.isArray(r.scope) && r.scope.length > 0 && r.error === null;
453
+ if (!declares) {
454
+ inRegion.push(r);
455
+ continue;
456
+ }
457
+ // A profile only leaves the region when a narrowed file can replace it. When any
458
+ // selected surface cannot narrow, the file would be the only home for a rule that
459
+ // reader cannot reach — so it stays, and says why.
460
+ if (narrowing) {
461
+ scoped.push(r);
462
+ continue;
463
+ }
464
+ inRegion.push(r);
465
+ notes.push(
466
+ `${r.id} declares simt-scope, but ${resolved.source} selects a surface that cannot narrow a rule, so it stays in the region`,
467
+ );
468
+ }
469
+
470
+ // The kernel. In `kernel` mode only the hard and command classes are packed into the
471
+ // region and every other rule is deferred to `rules show <id>`, which is what makes
472
+ // R08's accept criterion reachable at all: a budget cannot be met by a projection that
473
+ // never tries. `full` is the default and stays it until R02 has measured that a
474
+ // kernel does not lose compliance, because nobody has measured it yet.
475
+ //
476
+ // A rule with no stated class is deferred, not packed. The two mistakes are not
477
+ // symmetric: packing a line that should have been deferred spends the budget the mode
478
+ // exists to save, while deferring a line that should have been packed costs a reader
479
+ // one `rules show`. The mode is a promise about bytes, so it is the bytes that win.
480
+ const mode = await projectionMode(root);
481
+ const packed = [];
482
+ const deferred = [];
483
+ for (const r of inRegion) {
484
+ if (mode === 'kernel' && (r.error !== null || !KERNEL_CLASSES.has(r.priority))) deferred.push(r);
485
+ else packed.push(r);
486
+ }
487
+ // The command card leads the region. The exact commands for this repository are the
488
+ // bytes a reader cannot infer from rules that are identical everywhere (plan 0003 R10),
489
+ // so they go first — above even the core rules. It is derived from committed evidence
490
+ // only, in one place, so `plan` and `checkTarget` cannot disagree about it and two
491
+ // machines render it byte-identically.
492
+ const { card, proofs } = await inferableEvidence(root, { stack, facts, manifest });
493
+ if (card.dropped.length > 0) {
494
+ // Reported where every other projection note is reported. A card that lost a command
495
+ // in silence is a card a reader cannot tell is incomplete.
496
+ notes.push(
497
+ `command card: dropped ${card.dropped.join(', ')} to stay within ${CARD_LIMIT_BYTES} bytes`,
498
+ );
499
+ }
500
+ // Inferability (R09). A marked line leaves when the repository proves the evidence it
501
+ // names. `shipped` is a separate list from `packed` because they are separate decisions:
502
+ // the kernel is about which classes cost a reader nothing to skip, and this is about
503
+ // which lines the reader already knows.
504
+ const inferable = [];
505
+ const shipped = [];
506
+ for (const r of packed) {
507
+ const filtered = filterInferable(r.body, proofs);
508
+ if (!filtered.changed) {
509
+ shipped.push(r);
510
+ continue;
511
+ }
512
+ for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
513
+ shipped.push({ ...r, body: filtered.body });
514
+ }
515
+ let body = card.text ? `${card.text}\n\n${render(shipped)}` : render(shipped);
516
+ let sources = shipped.map((r) => r.source);
517
+ if (inferable.length > 0) {
518
+ // Never silent, and never a heading. A line that left with no trace repeats the
519
+ // mistake ADR-073 rejected for deferred rules, and a `## Dropped` section spends region
520
+ // bytes on lines the reader is being told not to read. A count they can act on, plus —
521
+ // with `--explain` — the evidence behind each one, is the third option.
522
+ notes.push(
523
+ `inferability: dropped ${inferable.length} line(s) this repository already proves; ${CLI} sync --explain names each one and the evidence behind it`,
524
+ );
525
+ if (flags?.explain === true) {
526
+ for (const x of inferable) {
527
+ notes.push(`inferable: ${x.rule} dropped "${x.text}" — proved by ${x.expr}`);
528
+ }
529
+ }
530
+ }
531
+ const folded = [];
532
+
533
+ // The pointer. A reader who opens AGENTS.md and does not find a profile they know is
534
+ // there will look for it, and the honest answer is where it moved: the file a scoping
535
+ // surface loads it from, and the files it now matches. Without the line the region's
536
+ // source list is quietly short one entry, which reads as a rule that was deleted.
537
+ if (scoped.length > 0 && narrowing) {
538
+ const lines = scoped.map(
539
+ (r) =>
540
+ `- ${sourceTitle(r, 'profile')} — loads only when a matching file is in play (${r.scope.join(', ')}): ${scopedPaths(r, resolved).join(', ')}`,
541
+ );
542
+ body += `\n\n## Scoped rules\n\n${lines.join('\n')}`;
543
+ for (const r of scoped) sources.push(r.source);
544
+ }
545
+
546
+ if (deferred.length > 0) {
547
+ // The pointer is the same shape as the scoped one above, and for the same reason:
548
+ // a reader who opens AGENTS.md and does not find a rule they know is there will
549
+ // look for it. What it costs to find it has to be one command, named per rule.
550
+ const lines = [...deferred]
551
+ .sort(byPriorityThenId)
552
+ .map(
553
+ (r) =>
554
+ `- ${sourceTitle(r, 'profile')} — ${r.priority ?? 'unclassified'}, deferred in kernel mode. Show it with: ${CLI} rules show ${r.id}`,
555
+ );
556
+ body += `\n\n## Deferred rules\n\n${lines.join('\n')}`;
557
+ for (const r of deferred) sources.push(r.source);
558
+ }
559
+
389
560
  if (foldsRules(resolved)) {
390
561
  for (const { rule, body: ruleBody } of bodies) {
391
562
  // `## Process:`, not `### id`. The body already uses `##`, and a third-level
@@ -407,6 +578,17 @@ export async function desiredRegion(root, flags) {
407
578
  all,
408
579
  profiles: selected.map((r) => r.id),
409
580
  folded,
581
+ mode,
582
+ deferred: deferred.map((r) => r.id),
583
+ // The marked lines that left, each with the evidence that dropped it. The region is
584
+ // written without them; `--explain` is what reads this list, so a removal is reportable
585
+ // without spending region bytes and without a second computation to find it.
586
+ inferable,
587
+ // The profiles that left the region, and the surfaces that accepted them as files.
588
+ // `plan` writes them and `checkTarget` gates them through this list, so a projected
589
+ // file and a drifted one are decided by the same computation.
590
+ scoped,
591
+ narrowing,
410
592
  surfaces: resolved.ids,
411
593
  surfaceSource: resolved.source,
412
594
  problems,
@@ -417,6 +599,27 @@ export async function desiredRegion(root, flags) {
417
599
  };
418
600
  }
419
601
 
602
+ /**
603
+ * Every per-rule file a scoped profile is written to, as paths a reader can open.
604
+ *
605
+ * Only the surfaces that can scope get one: a Cursor `.mdc` with `globs` and
606
+ * `alwaysApply: false`, an Antigravity file with `trigger: glob`, a Copilot
607
+ * `.instructions.md` with `applyTo`. A surface that reads the region already (opencode)
608
+ * has no file channel at all, and Claude's scoping key is the documented-defect one,
609
+ * so neither appears here.
610
+ */
611
+ function scopedPaths(rule, resolved) {
612
+ const out = [];
613
+ for (const s of resolved.surfaces) {
614
+ if (!canScope(s) || !s.rule) continue;
615
+ // A folded surface already carries the whole region; it is the reason this profile
616
+ // is only a file when folding is off, which is the caller's question to answer.
617
+ if (s.foldRules === true && s.readsPrimary === true && foldsRules(resolved)) continue;
618
+ out.push(rulePath(s.id, rule.id));
619
+ }
620
+ return out;
621
+ }
622
+
420
623
  /**
421
624
  * Read every rule file once, with its frontmatter parsed, plus any repo-local
422
625
  * profile under `.simt-dev/profiles/`.
@@ -444,7 +647,42 @@ export async function loadRules(root = null) {
444
647
  collected.push({ file, dir: RULES_DIR, origin: 'central', source: `rules/${file}` });
445
648
  }
446
649
 
650
+ // Packs go in before the repo's own profiles and after the built-ins, because
651
+ // that is the order they lose to. The collection order is the tie-break `select`
652
+ // reads for two profiles of equal rank, so "collected central, then pack, then
653
+ // local" is the precedence stated once rather than restated in the comparator.
447
654
  if (root !== null) {
655
+ const { packs } = await resolvePacks(root);
656
+ for (const pack of packs) {
657
+ // `present`, not `ok`. A pack whose bytes disagree with its pin still projects:
658
+ // dropping it would let the next `sync` delete the rules it carries, so the
659
+ // remedy the finding names would cause the damage it exists to report.
660
+ if (!pack.present) continue;
661
+ const packRules = join(pack.dir, 'rules');
662
+ let names = [];
663
+ try {
664
+ names = (await readdir(packRules)).filter((n) => n.endsWith('.md')).sort();
665
+ } catch {
666
+ names = [];
667
+ }
668
+ let stackNames = [];
669
+ try {
670
+ stackNames = (await readdir(join(packRules, 'stacks')))
671
+ .filter((n) => n.endsWith('.md'))
672
+ .map((n) => `stacks/${n}`);
673
+ } catch {
674
+ stackNames.length = 0;
675
+ }
676
+ for (const file of [...names, ...stackNames].sort()) {
677
+ collected.push({
678
+ file,
679
+ dir: packRules,
680
+ origin: 'pack',
681
+ source: `${PACK_DIR}/${pack.name}/rules/${file}`,
682
+ });
683
+ }
684
+ }
685
+
448
686
  const localDir = join(root, LOCAL_PROFILE_DIR);
449
687
  let localNames = [];
450
688
  try {
@@ -466,6 +704,7 @@ export async function loadRules(root = null) {
466
704
  for (const item of collected) {
467
705
  const text = await readFile(join(item.dir, item.file), 'utf8');
468
706
  const meta = parseFrontmatter(text);
707
+ const body = stripFrontmatter(text);
469
708
  out.push({
470
709
  file: item.file,
471
710
  origin: item.origin,
@@ -474,14 +713,81 @@ export async function loadRules(root = null) {
474
713
  title: meta['simt-title'] ?? null,
475
714
  applies: meta['simt-applies'] ?? [],
476
715
  fallback: meta['simt-fallback'] === true,
716
+ scope: Array.isArray(meta['simt-scope']) ? meta['simt-scope'] : null,
717
+ priority: meta['simt-priority'] ?? null,
477
718
  bytes: Buffer.byteLength(text),
478
- body: stripFrontmatter(text),
479
- error: describeDefect(meta),
719
+ body,
720
+ // A marker naming evidence the tool cannot check is a broken rule like any other, so
721
+ // it travels the field that already reaches `check` and `rules list`. The
722
+ // frontmatter defect wins, because a rule that cannot say what it applies to cannot
723
+ // be reasoned about at all.
724
+ error: describeDefect(meta) ?? inferableDefect(body),
480
725
  });
481
726
  }
482
727
  return out;
483
728
  }
484
729
 
730
+ /**
731
+ * The regions a monorepo's packages each need, as bodies ready to be written.
732
+ *
733
+ * A repository with packages in it is projected twice over: the root carries the
734
+ * rules every package shares, and each package carries the profiles IT selects and
735
+ * the root does not. A reader working in `apps/web` opens two files by walking up,
736
+ * and gets the union; a reader who only reads the root still gets everything that
737
+ * is not package-specific. Nothing is repeated, so nothing goes stale twice
738
+ * (ADR-066).
739
+ *
740
+ * A package whose delta is empty gets no file. Writing an empty region into every
741
+ * directory of a large monorepo would be noise the reader has to learn to skip,
742
+ * and a file with nothing in it is not a projection, it is an absence with a
743
+ * generated header on it.
744
+ *
745
+ * The rule set is the ROOT's - local profiles and packs are the repository's, and
746
+ * a package that re-read them from its own directory would resolve a different
747
+ * set in a directory that has no manifest.
748
+ *
749
+ * @returns {Promise<{regions: {path: string, ecosystem: string, stack: string[],
750
+ * facts: string[], profiles: string[], body: string, meta: {sha: string, sources: string[]}}[],
751
+ * notes: string[]}>}
752
+ */
753
+ export async function packageRegions(root, flags, rootProfiles = null) {
754
+ const shared = rootProfiles ?? (await desiredRegion(root, flags)).profiles;
755
+ const { packages, notes } = await discoverPackages(root);
756
+ if (packages.length === 0) return { regions: [], notes };
757
+
758
+ const rules = await loadRules(root);
759
+ const sha = await provenanceDigest();
760
+ // The ROOT's proofs, not the package's. The rule set is the root's, so which lines it
761
+ // already proves is a fact about the repository rather than about the directory a file
762
+ // lands in — filtering a package by the package's own facts would keep a rule here and
763
+ // drop it one directory up, which is the same rule treated as two.
764
+ const { proofs } = await inferableEvidence(root, await fingerprint(root));
765
+ const regions = [];
766
+ for (const pkg of packages) {
767
+ const fp = await fingerprint(join(root, pkg.path));
768
+ const { selected } = select(rules, fp.stack ?? [], fp.facts ?? []);
769
+ const delta = selected.filter((r) => !shared.includes(r.id));
770
+ if (delta.length === 0) continue;
771
+ const shipped = delta.map((r) => {
772
+ const filtered = filterInferable(r.body, proofs);
773
+ return filtered.changed ? { ...r, body: filtered.body } : r;
774
+ });
775
+ regions.push({
776
+ path: pkg.path,
777
+ ecosystem: pkg.ecosystem,
778
+ stack: fp.stack ?? [],
779
+ facts: fp.facts ?? [],
780
+ profiles: delta.map((r) => r.id),
781
+ // One sentence, because the file's whole purpose is to be found by a reader
782
+ // who does not know it exists: without it, a nested region reads as a
783
+ // complete set of rules and the shared half looks forgotten.
784
+ 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)}`,
785
+ meta: { sha, sources: delta.map((r) => r.source) },
786
+ });
787
+ }
788
+ return { regions, notes };
789
+ }
790
+
485
791
  /**
486
792
  * Selection is pure set intersection: core always, then every profile whose
487
793
  * `simt-applies` intersects the repo's signals. `node` is the fallback, so it is only
@@ -492,7 +798,7 @@ export async function loadRules(root = null) {
492
798
  * fact is the more durable choice because it survives the framework being
493
799
  * replaced.
494
800
  *
495
- * `shadowed` lists local profiles that replaced a central one, so a caller can
801
+ * `shadowed` lists profiles that replaced one they outrank, so a caller can
496
802
  * report it rather than silently changing what a repo receives.
497
803
  */
498
804
  export function select(rules, stack, facts = []) {
@@ -506,9 +812,14 @@ export function select(rules, stack, facts = []) {
506
812
  ids.set(rule.id, rule);
507
813
  continue;
508
814
  }
509
- // A local profile is allowed to replace a central one; two of the same
510
- // origin are a defect the caller must be able to see.
511
- const localWins = rule.origin === 'local' && prior.origin !== 'local';
815
+ // A profile may replace one it outranks; two of the same origin are a defect
816
+ // the caller must be able to see.
817
+ //
818
+ // Ranked rather than `=== 'local'` because there is now a third origin. The
819
+ // ordering it encodes - the repository's own profile, then a pack it declares,
820
+ // then what the tool ships - is the whole precedence rule, in one comparison
821
+ // that cannot drift from the order the files were collected in.
822
+ const localWins = ORIGIN_RANK[rule.origin] > ORIGIN_RANK[prior.origin];
512
823
  if (localWins) {
513
824
  ids.set(rule.id, rule);
514
825
  shadowed.push({
@@ -580,6 +891,32 @@ export function render(selected) {
580
891
  .join('\n\n');
581
892
  }
582
893
 
894
+ /**
895
+ * The projection mode this repository declared, or `full`.
896
+ *
897
+ * Absent means `full`, and `full` is the default rather than a fallback: no measurement
898
+ * yet says a kernel does not lose compliance (plan 0003 R02), so the mode that changes
899
+ * what a reader gets cannot be the one nobody chose.
900
+ */
901
+ async function projectionMode(root) {
902
+ const { manifest } = await readManifest(root);
903
+ return manifest?.mode === 'kernel' ? 'kernel' : 'full';
904
+ }
905
+
906
+ /**
907
+ * Deterministic kernel order: the class a rule declares, then its id.
908
+ *
909
+ * The tie-break is not decoration. Two rules of the same class packed in file order
910
+ * would move when a file is added to the directory, and a projection that reorders
911
+ * itself on an unrelated commit is a projection nobody can diff (ADR-013).
912
+ */
913
+ function byPriorityThenId(a, b) {
914
+ const classA = PRIORITY_CLASSES.indexOf(a.priority ?? 'style');
915
+ const classB = PRIORITY_CLASSES.indexOf(b.priority ?? 'style');
916
+ if (classA !== classB) return classA - classB;
917
+ return String(a.id).localeCompare(String(b.id));
918
+ }
919
+
583
920
  /**
584
921
  * One source, one `##` heading, then its body with every heading demoted.
585
922
  *
@@ -634,6 +971,12 @@ function parseFrontmatter(text) {
634
971
  .slice(1, raw.lastIndexOf(']'))
635
972
  .split(',')
636
973
  .map((s) => s.trim())
974
+ // A quoted item is a quoted item: the glob inside is the value, not the quotes
975
+ // around it. A profile author quoting `\"src/**/*.ts\"` because their editor
976
+ // colouring complains about a bare `*` would otherwise project a pattern the
977
+ // surface matches nothing against.
978
+ .map((s) => (s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s))
979
+ .map((s) => (s.length >= 2 && s.startsWith("'") && s.endsWith("'") ? s.slice(1, -1) : s))
637
980
  .filter(Boolean);
638
981
  } else out[key] = raw.trim();
639
982
  }