simt-dev 0.2.0 → 0.2.2

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 (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -27,10 +27,13 @@ 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
33
  import { BASELINES } from './baselines.mjs';
34
+ import { CLI } from '../name.mjs';
35
+ import { PACK_DIR, resolvePacks } from '../packs.mjs';
36
+ import { discoverPackages } from '../monorepo.mjs';
34
37
 
35
38
  const HERE = dirname(fileURLToPath(import.meta.url));
36
39
  export const ROOT = resolve(HERE, '..', '..');
@@ -57,13 +60,15 @@ const RULES_DIR = join(ROOT, 'rules');
57
60
  * unrecoverably. That is the failure this replaces (ADR-032).
58
61
  *
59
62
  * The digest covers every byte string that leaves the package, and nothing else. That
60
- * set is `rules/` and `graph/` on disk plus the RENDERED baseline bodies: the shared
63
+ * set is `rules/` on disk plus the RENDERED baseline bodies: the shared
61
64
  * `.editorconfig` and `.gitattributes` blocks are strings a repository receives, so by
62
65
  * ADR-015's rule a source is identified by its content rather than by the file it
63
- * happens to live in, and a body defined in `src/` is a source like any other.
66
+ * happens to live in, and a body defined in `src/` is a source like any other. The
67
+ * registry (`graph/`) is workspace data the package no longer ships (ADR-061), so it is
68
+ * not part of this set even where it exists on disk.
64
69
  *
65
- * It deliberately excludes `src/` as a directory. A refactor that changes no rule, no
66
- * registry entry and no baseline body produces byte-identical output, and stamping those
70
+ * It deliberately excludes `src/` as a directory. A refactor that changes no rule and
71
+ * no baseline body produces byte-identical output, and stamping those
67
72
  * consumers would report drift where there is none - which is the estate's own rule that
68
73
  * a report must be a fact.
69
74
  *
@@ -93,7 +98,7 @@ export async function provenanceDigest() {
93
98
  */
94
99
  export async function provenanceInputs() {
95
100
  const inputs = [];
96
- for (const dir of ['rules', 'graph']) {
101
+ for (const dir of ['rules']) {
97
102
  for (const rel of await walk(join(ROOT, dir))) {
98
103
  inputs.push({ path: `${dir}/${rel}`, bytes: await readFile(join(ROOT, dir, rel)) });
99
104
  }
@@ -121,11 +126,37 @@ async function walk(dir, prefix = '') {
121
126
  /** Repo-local profile directory, relative to a repo root (ADR-013 section 2). */
122
127
  export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
123
128
 
129
+ /**
130
+ * Which origin outranks which when two profiles claim one `simt-id`.
131
+ *
132
+ * A pack is the repository's shared policy and the built-in set is the tool's
133
+ * opinion, so a repository can replace either and a pack can replace what the
134
+ * tool ships. Read in one place because the answer to "whose rule is this?" is
135
+ * asked on every rule of every projection, and a precedence that lives in two
136
+ * places is a precedence that will disagree with itself.
137
+ */
138
+ const ORIGIN_RANK = { central: 0, pack: 1, local: 2 };
139
+
124
140
  /** Every key a stack profile or core rule may declare is this tool's own, so every
125
141
  * one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
126
142
  * as an editor's instruction — Cursor, Copilot and Antigravity all parse a
127
143
  * frontmatter block the same way — and would be silently inert. */
128
- const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
144
+ /** Frontmatter keys a rule profile declares. See {@link KNOWN_KEYS} on why exported. */
145
+ export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback', 'simt-scope', 'simt-priority']);
146
+
147
+ /**
148
+ * Priority classes, packed in this order (plan 0003 R07).
149
+ *
150
+ * A class is not a severity, it is a promise about what a reader loses by not having
151
+ * the line. A hard constraint is one whose absence produces a defect; a command is one
152
+ * the reader cannot guess; a convention is right to follow and harmless to miss; a style
153
+ * is a preference. Anything after `command` is the first thing a kernel drops, and that
154
+ * ordering is the whole content of the mode — everything else about it is bookkeeping.
155
+ */
156
+ export const PRIORITY_CLASSES = ['hard', 'command', 'convention', 'style'];
157
+
158
+ /** The classes a kernel keeps. `full` mode keeps all four and is the default. */
159
+ export const KERNEL_CLASSES = new Set(['hard', 'command']);
129
160
 
130
161
  /** Report the first thing wrong with a profile's frontmatter, in the order a reader
131
162
  * fixes it: no id to select it by, then a key that will not be read, then a tag the
@@ -144,14 +175,33 @@ function describeDefect(meta) {
144
175
  const prefixed = `simt-${unknown.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
145
176
  return PROFILE_KEYS.has(prefixed)
146
177
  ? `key "${unknown}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
147
- : `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback`;
178
+ : `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback, simt-scope`;
148
179
  }
149
180
  const applies = meta['simt-applies'];
150
181
  if (!Array.isArray(applies)) return null;
151
182
  const undeclared = applies.find((tag) => !KNOWN_TAGS.has(tag));
152
- return undeclared === undefined
153
- ? null
154
- : `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
183
+ if (undeclared !== undefined) {
184
+ return `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
185
+ }
186
+ const priority = meta['simt-priority'];
187
+ if (priority !== undefined && !PRIORITY_CLASSES.includes(priority)) {
188
+ return `simt-priority is "${priority}"; the classes are ${PRIORITY_CLASSES.join(', ')}, in the order a kernel packs them`;
189
+ }
190
+ const scope = meta['simt-scope'];
191
+ if (scope === undefined) return null;
192
+ // A scope that matches the whole repository is the eager profile wearing a costume:
193
+ // the reader is told the rule narrows, and the rule loads on every file. `**` and
194
+ // `**/*` are the two spellings of that, and both are the default a surface applies
195
+ // when it is given no pattern at all.
196
+ const CATCH_ALL = new Set(['**', '**/*', '*']);
197
+ if (scope.length === 0) {
198
+ return 'simt-scope is empty; a profile with no pattern would load as eagerly as one with no simt-scope at all';
199
+ }
200
+ const bad = scope.find((glob) => typeof glob !== 'string' || glob === '' || CATCH_ALL.has(glob));
201
+ if (bad !== undefined) {
202
+ 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`;
203
+ }
204
+ return null;
155
205
  }
156
206
 
157
207
  /**
@@ -293,8 +343,18 @@ async function ruleBodies(root, manifest, flags) {
293
343
 
294
344
  const declared = await readDeclared(root);
295
345
 
346
+ // Deliberately NOT folded in. This array becomes `mdc.problems`, and `sync` reports it
347
+ // as "canonical .mdc rule(s) cannot be projected" with the remedy "fix the reported
348
+ // substitution keys in simt.json". A surface typo is not a rule problem and has no
349
+ // substitution keys — `surfaces: ["curosr"]` was reported to a reader as though they
350
+ // had misspelled a key in a block they had never heard of, and the only way to find the
351
+ // real cause was to run `check`, which already said it plainly. Two surfaces report the
352
+ // same problem; only one of them names it correctly, and the one that mislabels it is
353
+ // the one the writer uses.
354
+ //
355
+ // Nothing is lost by dropping it here: `plan` returns `surfaceProblems` from this same
356
+ // `resolveSurfaces` call, and `checkTargets` reads it independently at project.mjs.
296
357
  const resolved = resolveSurfaces(manifest, flags ?? {});
297
- for (const p of resolved.problems) problems.push(p);
298
358
 
299
359
  // A manifest key that no shipped rule declares is a typo, or a leftover from a rule
300
360
  // that was renamed. Both read as "configured" in the one file a reviewer opens to
@@ -358,21 +418,88 @@ export async function desiredRegion(root, flags) {
358
418
  const { stack, facts, manifest } = await fingerprint(root);
359
419
  const all = await loadRules(root);
360
420
  const { selected, fallback, notes } = select(all, stack, facts);
361
-
362
- let body = render(selected);
363
- let sources = selected.map((r) => r.source);
364
- const folded = [];
365
-
366
- // When every selected surface carries its rules inside the primary document, the
367
- // per-rule bodies belong in the region too — otherwise a repo on the standard
368
- // surface receives AGENTS.md and none of the always-on process rules, because
369
- // those had nowhere else to live. One file, one region, one place a reader opens.
370
421
  const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
371
422
  root,
372
423
  manifest,
373
424
  flags ?? {},
374
425
  );
375
426
  notes.push(...ruleNotes);
427
+
428
+ // A profile that declares a scope leaves the region only when every selected surface
429
+ // can load it narrowly. The alternative is a profile written only as a scoped file for
430
+ // a reader whose surface cannot scope — a rule nobody loads, which is worse than one
431
+ // that loads eagerly (ADR-071). A surface that cannot scope it therefore keeps the
432
+ // profile in the region and says so, rather than letting the region quietly shrink.
433
+ const narrowing = resolved.surfaces.every((s) => canScope(s));
434
+ const scoped = [];
435
+ const inRegion = [];
436
+ for (const r of selected) {
437
+ const declares = Array.isArray(r.scope) && r.scope.length > 0 && r.error === null;
438
+ if (!declares) {
439
+ inRegion.push(r);
440
+ continue;
441
+ }
442
+ // A profile only leaves the region when a narrowed file can replace it. When any
443
+ // selected surface cannot narrow, the file would be the only home for a rule that
444
+ // reader cannot reach — so it stays, and says why.
445
+ if (narrowing) {
446
+ scoped.push(r);
447
+ continue;
448
+ }
449
+ inRegion.push(r);
450
+ notes.push(
451
+ `${r.id} declares simt-scope, but ${resolved.source} selects a surface that cannot narrow a rule, so it stays in the region`,
452
+ );
453
+ }
454
+
455
+ // The kernel. In `kernel` mode only the hard and command classes are packed into the
456
+ // region and every other rule is deferred to `rules show <id>`, which is what makes
457
+ // R08's accept criterion reachable at all: a budget cannot be met by a projection that
458
+ // never tries. `full` is the default and stays it until R02 has measured that a
459
+ // kernel does not lose compliance, because nobody has measured it yet.
460
+ //
461
+ // A rule with no stated class is deferred, not packed. The two mistakes are not
462
+ // symmetric: packing a line that should have been deferred spends the budget the mode
463
+ // exists to save, while deferring a line that should have been packed costs a reader
464
+ // one `rules show`. The mode is a promise about bytes, so it is the bytes that win.
465
+ const mode = await projectionMode(root);
466
+ const packed = [];
467
+ const deferred = [];
468
+ for (const r of inRegion) {
469
+ if (mode === 'kernel' && (r.error !== null || !KERNEL_CLASSES.has(r.priority))) deferred.push(r);
470
+ else packed.push(r);
471
+ }
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(', ')}`,
484
+ );
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
+ }
502
+
376
503
  if (foldsRules(resolved)) {
377
504
  for (const { rule, body: ruleBody } of bodies) {
378
505
  // `## Process:`, not `### id`. The body already uses `##`, and a third-level
@@ -394,6 +521,13 @@ export async function desiredRegion(root, flags) {
394
521
  all,
395
522
  profiles: selected.map((r) => r.id),
396
523
  folded,
524
+ mode,
525
+ deferred: deferred.map((r) => r.id),
526
+ // The profiles that left the region, and the surfaces that accepted them as files.
527
+ // `plan` writes them and `checkTarget` gates them through this list, so a projected
528
+ // file and a drifted one are decided by the same computation.
529
+ scoped,
530
+ narrowing,
397
531
  surfaces: resolved.ids,
398
532
  surfaceSource: resolved.source,
399
533
  problems,
@@ -404,6 +538,27 @@ export async function desiredRegion(root, flags) {
404
538
  };
405
539
  }
406
540
 
541
+ /**
542
+ * Every per-rule file a scoped profile is written to, as paths a reader can open.
543
+ *
544
+ * Only the surfaces that can scope get one: a Cursor `.mdc` with `globs` and
545
+ * `alwaysApply: false`, an Antigravity file with `trigger: glob`, a Copilot
546
+ * `.instructions.md` with `applyTo`. A surface that reads the region already (opencode)
547
+ * has no file channel at all, and Claude's scoping key is the documented-defect one,
548
+ * so neither appears here.
549
+ */
550
+ function scopedPaths(rule, resolved) {
551
+ const out = [];
552
+ for (const s of resolved.surfaces) {
553
+ if (!canScope(s) || !s.rule) continue;
554
+ // A folded surface already carries the whole region; it is the reason this profile
555
+ // is only a file when folding is off, which is the caller's question to answer.
556
+ if (s.foldRules === true && s.readsPrimary === true && foldsRules(resolved)) continue;
557
+ out.push(rulePath(s.id, rule.id));
558
+ }
559
+ return out;
560
+ }
561
+
407
562
  /**
408
563
  * Read every rule file once, with its frontmatter parsed, plus any repo-local
409
564
  * profile under `.simt-dev/profiles/`.
@@ -431,7 +586,42 @@ export async function loadRules(root = null) {
431
586
  collected.push({ file, dir: RULES_DIR, origin: 'central', source: `rules/${file}` });
432
587
  }
433
588
 
589
+ // Packs go in before the repo's own profiles and after the built-ins, because
590
+ // that is the order they lose to. The collection order is the tie-break `select`
591
+ // reads for two profiles of equal rank, so "collected central, then pack, then
592
+ // local" is the precedence stated once rather than restated in the comparator.
434
593
  if (root !== null) {
594
+ const { packs } = await resolvePacks(root);
595
+ for (const pack of packs) {
596
+ // `present`, not `ok`. A pack whose bytes disagree with its pin still projects:
597
+ // dropping it would let the next `sync` delete the rules it carries, so the
598
+ // remedy the finding names would cause the damage it exists to report.
599
+ if (!pack.present) continue;
600
+ const packRules = join(pack.dir, 'rules');
601
+ let names = [];
602
+ try {
603
+ names = (await readdir(packRules)).filter((n) => n.endsWith('.md')).sort();
604
+ } catch {
605
+ names = [];
606
+ }
607
+ let stackNames = [];
608
+ try {
609
+ stackNames = (await readdir(join(packRules, 'stacks')))
610
+ .filter((n) => n.endsWith('.md'))
611
+ .map((n) => `stacks/${n}`);
612
+ } catch {
613
+ stackNames.length = 0;
614
+ }
615
+ for (const file of [...names, ...stackNames].sort()) {
616
+ collected.push({
617
+ file,
618
+ dir: packRules,
619
+ origin: 'pack',
620
+ source: `${PACK_DIR}/${pack.name}/rules/${file}`,
621
+ });
622
+ }
623
+ }
624
+
435
625
  const localDir = join(root, LOCAL_PROFILE_DIR);
436
626
  let localNames = [];
437
627
  try {
@@ -461,6 +651,8 @@ export async function loadRules(root = null) {
461
651
  title: meta['simt-title'] ?? null,
462
652
  applies: meta['simt-applies'] ?? [],
463
653
  fallback: meta['simt-fallback'] === true,
654
+ scope: Array.isArray(meta['simt-scope']) ? meta['simt-scope'] : null,
655
+ priority: meta['simt-priority'] ?? null,
464
656
  bytes: Buffer.byteLength(text),
465
657
  body: stripFrontmatter(text),
466
658
  error: describeDefect(meta),
@@ -469,6 +661,58 @@ export async function loadRules(root = null) {
469
661
  return out;
470
662
  }
471
663
 
664
+ /**
665
+ * The regions a monorepo's packages each need, as bodies ready to be written.
666
+ *
667
+ * A repository with packages in it is projected twice over: the root carries the
668
+ * rules every package shares, and each package carries the profiles IT selects and
669
+ * the root does not. A reader working in `apps/web` opens two files by walking up,
670
+ * and gets the union; a reader who only reads the root still gets everything that
671
+ * is not package-specific. Nothing is repeated, so nothing goes stale twice
672
+ * (ADR-066).
673
+ *
674
+ * A package whose delta is empty gets no file. Writing an empty region into every
675
+ * directory of a large monorepo would be noise the reader has to learn to skip,
676
+ * and a file with nothing in it is not a projection, it is an absence with a
677
+ * generated header on it.
678
+ *
679
+ * The rule set is the ROOT's - local profiles and packs are the repository's, and
680
+ * a package that re-read them from its own directory would resolve a different
681
+ * set in a directory that has no manifest.
682
+ *
683
+ * @returns {Promise<{regions: {path: string, ecosystem: string, stack: string[],
684
+ * facts: string[], profiles: string[], body: string, meta: {sha: string, sources: string[]}}[],
685
+ * notes: string[]}>}
686
+ */
687
+ export async function packageRegions(root, flags, rootProfiles = null) {
688
+ const shared = rootProfiles ?? (await desiredRegion(root, flags)).profiles;
689
+ const { packages, notes } = await discoverPackages(root);
690
+ if (packages.length === 0) return { regions: [], notes };
691
+
692
+ const rules = await loadRules(root);
693
+ const sha = await provenanceDigest();
694
+ const regions = [];
695
+ for (const pkg of packages) {
696
+ const fp = await fingerprint(join(root, pkg.path));
697
+ const { selected } = select(rules, fp.stack ?? [], fp.facts ?? []);
698
+ const delta = selected.filter((r) => !shared.includes(r.id));
699
+ if (delta.length === 0) continue;
700
+ regions.push({
701
+ path: pkg.path,
702
+ ecosystem: pkg.ecosystem,
703
+ stack: fp.stack ?? [],
704
+ facts: fp.facts ?? [],
705
+ profiles: delta.map((r) => r.id),
706
+ // One sentence, because the file's whole purpose is to be found by a reader
707
+ // who does not know it exists: without it, a nested region reads as a
708
+ // 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)}`,
710
+ meta: { sha, sources: delta.map((r) => r.source) },
711
+ });
712
+ }
713
+ return { regions, notes };
714
+ }
715
+
472
716
  /**
473
717
  * Selection is pure set intersection: core always, then every profile whose
474
718
  * `simt-applies` intersects the repo's signals. `node` is the fallback, so it is only
@@ -479,7 +723,7 @@ export async function loadRules(root = null) {
479
723
  * fact is the more durable choice because it survives the framework being
480
724
  * replaced.
481
725
  *
482
- * `shadowed` lists local profiles that replaced a central one, so a caller can
726
+ * `shadowed` lists profiles that replaced one they outrank, so a caller can
483
727
  * report it rather than silently changing what a repo receives.
484
728
  */
485
729
  export function select(rules, stack, facts = []) {
@@ -493,9 +737,14 @@ export function select(rules, stack, facts = []) {
493
737
  ids.set(rule.id, rule);
494
738
  continue;
495
739
  }
496
- // A local profile is allowed to replace a central one; two of the same
497
- // origin are a defect the caller must be able to see.
498
- const localWins = rule.origin === 'local' && prior.origin !== 'local';
740
+ // A profile may replace one it outranks; two of the same origin are a defect
741
+ // the caller must be able to see.
742
+ //
743
+ // Ranked rather than `=== 'local'` because there is now a third origin. The
744
+ // ordering it encodes - the repository's own profile, then a pack it declares,
745
+ // then what the tool ships - is the whole precedence rule, in one comparison
746
+ // that cannot drift from the order the files were collected in.
747
+ const localWins = ORIGIN_RANK[rule.origin] > ORIGIN_RANK[prior.origin];
499
748
  if (localWins) {
500
749
  ids.set(rule.id, rule);
501
750
  shadowed.push({
@@ -567,6 +816,32 @@ export function render(selected) {
567
816
  .join('\n\n');
568
817
  }
569
818
 
819
+ /**
820
+ * The projection mode this repository declared, or `full`.
821
+ *
822
+ * Absent means `full`, and `full` is the default rather than a fallback: no measurement
823
+ * yet says a kernel does not lose compliance (plan 0003 R02), so the mode that changes
824
+ * what a reader gets cannot be the one nobody chose.
825
+ */
826
+ async function projectionMode(root) {
827
+ const { manifest } = await readManifest(root);
828
+ return manifest?.mode === 'kernel' ? 'kernel' : 'full';
829
+ }
830
+
831
+ /**
832
+ * Deterministic kernel order: the class a rule declares, then its id.
833
+ *
834
+ * The tie-break is not decoration. Two rules of the same class packed in file order
835
+ * would move when a file is added to the directory, and a projection that reorders
836
+ * itself on an unrelated commit is a projection nobody can diff (ADR-013).
837
+ */
838
+ function byPriorityThenId(a, b) {
839
+ const classA = PRIORITY_CLASSES.indexOf(a.priority ?? 'style');
840
+ const classB = PRIORITY_CLASSES.indexOf(b.priority ?? 'style');
841
+ if (classA !== classB) return classA - classB;
842
+ return String(a.id).localeCompare(String(b.id));
843
+ }
844
+
570
845
  /**
571
846
  * One source, one `##` heading, then its body with every heading demoted.
572
847
  *
@@ -574,7 +849,7 @@ export function render(selected) {
574
849
  * so it lands directly under `## Stack: …` instead of under whatever section the
575
850
  * previous source ended on.
576
851
  */
577
- export function frameSource(rule, body, kind) {
852
+ function frameSource(rule, body, kind) {
578
853
  const text = demoteHeadings(body).trim();
579
854
  if (!text) return '';
580
855
  const title = sourceTitle(rule, kind);
@@ -621,6 +896,12 @@ function parseFrontmatter(text) {
621
896
  .slice(1, raw.lastIndexOf(']'))
622
897
  .split(',')
623
898
  .map((s) => s.trim())
899
+ // A quoted item is a quoted item: the glob inside is the value, not the quotes
900
+ // around it. A profile author quoting `\"src/**/*.ts\"` because their editor
901
+ // colouring complains about a bare `*` would otherwise project a pattern the
902
+ // surface matches nothing against.
903
+ .map((s) => (s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s))
904
+ .map((s) => (s.length >= 2 && s.startsWith("'") && s.endsWith("'") ? s.slice(1, -1) : s))
624
905
  .filter(Boolean);
625
906
  } else out[key] = raw.trim();
626
907
  }