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
@@ -29,6 +29,7 @@ import { getGate } from '../hooks/gates.mjs';
29
29
  // file by name.
30
30
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
31
31
  import { CLI } from '../name.mjs';
32
+ import { packageVersion } from '../package-version.mjs';
32
33
  export const MANIFEST = MANIFEST_FILE;
33
34
 
34
35
  export { MANIFEST_LEGACY };
@@ -98,6 +99,93 @@ const TAG_GATES = [
98
99
  { tag: 'hono', preCommit: ['i18n-completeness'] },
99
100
  ];
100
101
 
102
+ /**
103
+ * The ecosystems that produce a fact rather than a stack tag, and what a fresh
104
+ * adoption of one should declare.
105
+ *
106
+ * A row proposes gates the way `GATES_BY_STACK` does, and the fact it keys on is
107
+ * the same signal a profile's `simt-applies` names, so one marker file reaches
108
+ * both: the region this repo reads and the gates its commits run.
109
+ *
110
+ * `commands` is what a FRESH manifest declares for each proposed gate, and it is
111
+ * written only when the probe in `readCapability` found `tool` where this row says
112
+ * it lives. A declared gate that cannot run blocks every commit, so a command is
113
+ * never declared on the faith of a marker file: the marker says the ecosystem is
114
+ * here, the probe says its program can run (ADR-017). A command the repo already
115
+ * declared wins over the seed, because a seed is a default and a declaration is a
116
+ * choice.
117
+ *
118
+ * `file` marks a tool that is a file in the repository rather than a binary on
119
+ * PATH: the Maven wrapper, probed the way this tool already probes `gradlew`.
120
+ */
121
+ export const ECOSYSTEMS = [
122
+ {
123
+ fact: 'has-cargo-toml',
124
+ tool: 'cargo',
125
+ preCommit: ['format', 'lint'],
126
+ prePush: ['test'],
127
+ commands: { format: 'cargo fmt --check', lint: 'cargo clippy -- -D warnings', test: 'cargo test' },
128
+ },
129
+ {
130
+ fact: 'has-go-mod',
131
+ tool: 'go',
132
+ preCommit: ['format', 'lint'],
133
+ prePush: ['test'],
134
+ commands: { format: 'test -z "$(gofmt -l .)"', lint: 'go vet ./...', test: 'go test ./...' },
135
+ },
136
+ {
137
+ fact: 'has-nuget',
138
+ tool: 'dotnet',
139
+ preCommit: ['format'],
140
+ prePush: ['test', 'typecheck'],
141
+ commands: { format: 'dotnet format --verify-no-changes', test: 'dotnet test', typecheck: 'dotnet build -warnaserror' },
142
+ },
143
+ {
144
+ fact: 'has-swift-package',
145
+ tool: 'swift',
146
+ preCommit: ['format'],
147
+ prePush: ['test', 'typecheck'],
148
+ commands: { format: 'swift format lint -r .', test: 'swift test', typecheck: 'swift build' },
149
+ },
150
+ {
151
+ fact: 'has-deno-config',
152
+ tool: 'deno',
153
+ preCommit: ['format', 'lint'],
154
+ prePush: ['test', 'typecheck'],
155
+ commands: { format: 'deno fmt --check', lint: 'deno lint', test: 'deno test', typecheck: 'deno check .' },
156
+ },
157
+ {
158
+ fact: 'has-gemfile',
159
+ tool: 'bundle',
160
+ preCommit: ['format'],
161
+ prePush: ['test'],
162
+ commands: { format: 'bundle exec rubocop', test: 'bundle exec rspec' },
163
+ },
164
+ {
165
+ fact: 'has-pubspec',
166
+ tool: 'dart',
167
+ preCommit: ['format', 'lint'],
168
+ prePush: ['test'],
169
+ commands: { format: 'dart format --output=none --set-exit-if-changed .', lint: 'dart analyze', test: 'dart test' },
170
+ },
171
+ {
172
+ fact: 'has-mix',
173
+ tool: 'mix',
174
+ preCommit: ['format'],
175
+ prePush: ['test'],
176
+ commands: { format: 'mix format --check-formatted', test: 'mix test' },
177
+ },
178
+ // The wrapper is a file the repository commits, not a program on PATH. A repo with
179
+ // a pom.xml and no wrapper cannot run `./mvnw -q verify`, so the probe is the file.
180
+ {
181
+ fact: 'has-maven-pom',
182
+ tool: 'mvnw',
183
+ file: true,
184
+ prePush: ['test'],
185
+ commands: { test: './mvnw -q verify' },
186
+ },
187
+ ];
188
+
101
189
  /**
102
190
  * Can this repo actually run the gate?
103
191
  *
@@ -240,22 +328,68 @@ function runnerName(runner) {
240
328
  * - `command` gates run whatever `gates.commands` declares, so a command gate with
241
329
  * no declared command is unrunnable by definition
242
330
  *
243
- * @returns {{gates: {preCommit: string[], prePush: string[]}, dropped: {gate: string, why: string}[]}}
331
+ * The seeds are returned alongside the plan because a fresh adoption writes them
332
+ * into `gates.commands` and the reader is owed the line that says so: a command
333
+ * that lands in the manifest without appearing in the plan is a silent write.
334
+ *
335
+ * @returns {{gates: {preCommit: string[], prePush: string[]}, dropped: {gate: string, why: string}[], commands: Record<string,string>}}
244
336
  */
245
- export function planGates(stack = [], capability = null) {
246
- const proposed = defaultGates(stack);
247
- if (!capability) return { gates: proposed, dropped: [] };
337
+ export function planGates(stack = [], capability = null, facts = []) {
338
+ const proposed = defaultGates(stack, facts);
339
+ if (!capability) return { gates: proposed, dropped: [], commands: {} };
340
+ const seeds = ecosystemCommands(facts, capability);
341
+ // A command the repo already declared wins over a seed: the seed is a default,
342
+ // the declaration is a choice, and a default overwriting a choice is a re-init
343
+ // that edits the repo's mind.
344
+ const effective = { ...capability, commands: { ...seeds, ...capability.commands } };
248
345
  const dropped = [];
249
346
  const runnable = (list) =>
250
347
  list.filter((name) => {
251
348
  const gate = getGate(name);
252
- const verdict = gateRunnable(gate, capability);
349
+ const verdict = gateRunnable(gate, effective);
253
350
  if (!verdict.runnable) {
254
351
  dropped.push({ gate: name, why: verdict.why, remedy: gateRemedy(gate, { runner: capability.runner }) });
255
352
  }
256
353
  return verdict.runnable;
257
354
  });
258
- return { gates: { preCommit: runnable(proposed.preCommit), prePush: runnable(proposed.prePush) }, dropped };
355
+ return {
356
+ gates: { preCommit: runnable(proposed.preCommit), prePush: runnable(proposed.prePush) },
357
+ dropped,
358
+ commands: seeds,
359
+ };
360
+ }
361
+
362
+ /**
363
+ * The canonical `gates.commands` a fresh manifest of this repository should carry.
364
+ *
365
+ * Two probes, and both must pass before a command is declared: the fact says the
366
+ * ecosystem is in the repository, and `capability.tools` says its program can run
367
+ * on this machine. A marker file without a toolchain would otherwise declare a
368
+ * gate that fails closed on every commit — the defect ADR-017 exists to prevent,
369
+ * reached from the ecosystem table rather than the stack table.
370
+ *
371
+ * Pure, so `planGates` and `buildManifest` answer with one implementation.
372
+ */
373
+ function ecosystemCommands(facts = [], capability = null) {
374
+ if (!capability?.tools?.length) return {};
375
+ // An npm runner already answers "how do I test and format" with its own package.json
376
+ // scripts, and a seed only wins over those scripts when the repo has not declared a
377
+ // command of its own — which is exactly the mixed repos (package.json AND Cargo.toml)
378
+ // where the script is the deliberate answer. Seeding there would replace a choice with
379
+ // a default; the reader's escape hatch is declaring gates.commands by hand, which a
380
+ // seed would lose to anyway. Pure ecosystem repos have runner 'cargo'/'go'/... and
381
+ // seed normally.
382
+ if (capability.runner === 'npm') return {};
383
+ const present = new Set(facts);
384
+ const commands = {};
385
+ for (const eco of ECOSYSTEMS) {
386
+ if (!present.has(eco.fact)) continue;
387
+ if (!capability.tools.includes(eco.tool)) continue;
388
+ for (const [gate, command] of Object.entries(eco.commands)) {
389
+ if (!capability.commands?.[gate]) commands[gate] = command;
390
+ }
391
+ }
392
+ return commands;
259
393
  }
260
394
 
261
395
  /**
@@ -267,16 +401,22 @@ export function planGates(stack = [], capability = null) {
267
401
  * make, and quietly disarming a gate somebody declared on purpose is a worse surprise
268
402
  * than a visible one.
269
403
  */
270
- export function buildManifest({ root, stack = [], sha, existing = null, capability = null, surfaces = undefined }) {
271
- const gates = defaultGates(stack);
404
+ export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
405
+ const gates = defaultGates(stack, facts);
272
406
  const limits = defaultLimits(stack);
273
407
 
274
408
  // A re-init must never silently weaken a repo's own declaration: existing
275
409
  // limits and gates win over defaults, and only the projection stamp is refreshed.
276
410
  const limitsFinal = existing?.limits ? { ...limits, ...existing.limits } : limits;
411
+ const planned = planGates(stack, capability, facts);
412
+ // The seeds land only on a FRESH manifest. On an existing one the repo's
413
+ // declaration is the answer, and adding commands under it would be a re-init
414
+ // writing into a field the reader owns — the same rule as the gates above.
277
415
  const gatesFinal = existing?.gates
278
416
  ? { ...gates, ...existing.gates }
279
- : planGates(stack, capability).gates;
417
+ : Object.keys(planned.commands).length > 0
418
+ ? { ...planned.gates, commands: planned.commands }
419
+ : planned.gates;
280
420
 
281
421
  return {
282
422
  ...MANIFEST_COMMENT,
@@ -296,6 +436,29 @@ export function buildManifest({ root, stack = [], sha, existing = null, capabili
296
436
  // substitution value: it lists the rules this repo declares it has a problem
297
437
  // with, which is something detection cannot infer.
298
438
  substitutions: existing?.substitutions ?? {},
439
+ // ADR-065: rule sets this repository shares with its siblings, and the digest
440
+ // of the bytes it has for each. Not a generated field - which packs a repo uses
441
+ // is the repo's decision, and `check` reports a digest that does not match the
442
+ // directory rather than a value here that differs from what the tool would pick.
443
+ packs: existing?.packs ?? [],
444
+ // ADR-072: how many bytes of always-on projection this repository has agreed to
445
+ // carry. A declaration, not a generated field, for the same reason `packs` is not:
446
+ // the number is the repository's own line in the sand, and `upgrade` must not
447
+ // rewrite it to whatever the tool happens to project today — a budget that moved by
448
+ // itself would be a budget nobody is held to.
449
+ //
450
+ // Absent when the repository declared nothing, which is the point. Writing the
451
+ // default here would make every adopted repo look like it had chosen it, and a
452
+ // reader told "declared: true" about a line nobody wrote is being lied to about the
453
+ // only thing the number is for. The default belongs to whoever measures, and
454
+ // `budgetFinding` and `simt tokens` both read it from one place.
455
+ ...(existing?.budget === undefined ? {} : { budget: existing.budget }),
456
+ // ADR-073: `full` projects every selected rule; `kernel` projects only the hard
457
+ // constraints and defers the rest to `rules show`. Absent means `full`, and the
458
+ // default stays `full` because nobody has yet measured whether a kernel loses
459
+ // compliance (plan 0003 R02) — a mode chosen by default would be a bet the
460
+ // repository never made. Declared, not generated, for the same reason as `budget`.
461
+ ...(existing?.mode === undefined ? {} : { mode: existing.mode }),
299
462
  // ADR-020: which editors this repo's rules are projected into. Absent means the
300
463
  // global standard applies; an explicit empty list is a real choice meaning
301
464
  // AGENTS.md only. Recorded rather than inferred, because "which IDE does this repo
@@ -303,7 +466,12 @@ export function buildManifest({ root, stack = [], sha, existing = null, capabili
303
466
  // because a typo here silently reduces the estate to AGENTS.md, which looks
304
467
  // exactly like a working setup.
305
468
  surfaces: surfaces ?? existing?.surfaces ?? null,
306
- projection: { simtDevSha: sha ?? null },
469
+ // simtDevSha is the digest of the rule bytes this projection was rendered from;
470
+ // version is the simt-dev release that produced it. The gate compares the running
471
+ // binary to version and refuses on a mismatch, which is how a repository without
472
+ // npm (a global install, pinned only by this field) is held to the release it
473
+ // adopted. See src/package-version.mjs.
474
+ projection: { simtDevSha: sha ?? null, version: version ?? null },
307
475
  };
308
476
  }
309
477
 
@@ -325,6 +493,24 @@ export function unrunnableGates(manifest, capability) {
325
493
  ];
326
494
  const findings = [];
327
495
  const seen = new Set();
496
+ // `gates.cwd` names a directory a gate runs in, and a name that is not a declared
497
+ // gate is as much a landmine as one that cannot run: the entry is inert, the reader
498
+ // believes the gate runs in the package it names, and nothing anywhere says so. Same
499
+ // shape as the unknown-gate finding in the dispatcher, so both are repaired the same
500
+ // way.
501
+ const cwds = manifest?.gates?.cwd;
502
+ if (cwds && typeof cwds === 'object' && !Array.isArray(cwds)) {
503
+ for (const name of Object.keys(cwds).sort()) {
504
+ if (declared.includes(name)) continue;
505
+ findings.push({
506
+ file: MANIFEST,
507
+ status: 'unknown-gate-cwd',
508
+ gate: name,
509
+ detail: `gates.cwd names "${name}", which is not declared in gates.preCommit or gates.prePush, so it is never read`,
510
+ remedy: `declare "${name}" in gates.preCommit or gates.prePush, or remove gates.cwd["${name}"]`,
511
+ });
512
+ }
513
+ }
328
514
  for (const name of declared) {
329
515
  if (seen.has(name)) continue;
330
516
  seen.add(name);
@@ -348,18 +534,30 @@ function defaultLimits() {
348
534
  }
349
535
 
350
536
  /** ADR-006: coverage stays null. Set it only if the repo has a real numeric gate. */
351
- function defaultGates(stack) {
352
- const tags = new Set(stack);
537
+ function defaultGates(stack, facts = []) {
538
+ // Tags and facts are one signal set for gate purposes: a row in `GATES_BY_STACK`
539
+ // keys on a tag, a row in `ECOSYSTEMS` on a fact, and the two never name each
540
+ // other's vocabulary (facts are `has-*`, tags are not), so one union cannot
541
+ // cross-match.
542
+ const signals = new Set([...stack, ...facts]);
353
543
  const gates = { preCommit: [], prePush: [] };
354
544
 
355
545
  for (const row of GATES_BY_STACK) {
356
- if (row.when.some((tag) => tags.has(tag))) {
546
+ if (row.when.some((tag) => signals.has(tag))) {
357
547
  gates.preCommit.push(...row.preCommit);
358
548
  gates.prePush.push(...row.prePush);
359
549
  }
360
550
  }
551
+ for (const row of ECOSYSTEMS) {
552
+ if (signals.has(row.fact)) {
553
+ // A row proposes what it has: the Maven row has no preCommit gates, so a
554
+ // missing key is "none", not a defect. Spreading undefined throws.
555
+ gates.preCommit.push(...(row.preCommit ?? []));
556
+ gates.prePush.push(...(row.prePush ?? []));
557
+ }
558
+ }
361
559
  for (const row of TAG_GATES) {
362
- if (tags.has(row.tag)) gates.preCommit.push(...row.preCommit);
560
+ if (signals.has(row.tag)) gates.preCommit.push(...row.preCommit);
363
561
  }
364
562
 
365
563
  // A repo with no recognised stack still gets a gate list, because a gate that
@@ -388,6 +586,86 @@ function defaultId(root) {
388
586
  return name === '' || name === '.' ? 'unnamed-repo' : name;
389
587
  }
390
588
 
589
+ /**
590
+ * The fields this tool writes. Everything else in a manifest belongs to the
591
+ * repository that adopted the tool.
592
+ *
593
+ * It is a value rather than an expression inside `manifestDrift` because the question
594
+ * "is this field ours?" now has two callers that must answer it identically: the gate
595
+ * that reports one stale, and `sync`, which repairs exactly the fields the gate names.
596
+ * A second implementation of this set is a repair that fixes a field nothing reports,
597
+ * or a report about a field nothing repairs - and each half passes its own test,
598
+ * which is ADR-045's shape reached from the other direction.
599
+ */
600
+ /**
601
+ * The bytes of always-on projection a repository carries per root before the number is
602
+ * worth an argument.
603
+ *
604
+ * Thirty thousand is S38's figure and not a measurement this tool took: it is the line
605
+ * the review drew, the one the whole row exists to check. A default nobody would accept
606
+ * is a default that reports nothing, and a budget only works as a budget when exceeding
607
+ * it is something a reader can see.
608
+ */
609
+ export const DEFAULT_BUDGET_BYTES = 30000;
610
+
611
+ /**
612
+ * Report a projection that carries more than the repository agreed to carry.
613
+ *
614
+ * Non-blocking by design, and that is the honest answer rather than a convenient one:
615
+ * the plan's own wording is that `check` reports `BUDGET-CUT` and nothing is cut, because
616
+ * cutting requires a kernel to pack lines into and the kernel does not exist yet
617
+ * (R07, blocked on the R02 evaluation). A gate that failed here would fail every
618
+ * repository in the estate on a number this tool has not yet earned the right to enforce.
619
+ *
620
+ * The finding names what is over and what is NOT cut, because a reader who is told only
621
+ * that a budget was exceeded will assume something was removed and stop looking.
622
+ *
623
+ * @returns {{file: string, status: string, detail: string, remedy: string}|null}
624
+ */
625
+ export function budgetFinding(bytes, budget, mode = 'full') {
626
+ const allowed = budget?.bytes ?? DEFAULT_BUDGET_BYTES;
627
+ if (!Number.isFinite(bytes) || bytes <= allowed) return null;
628
+ // The two modes answer different questions, and conflating them is the one way this
629
+ // finding could be actively wrong. In `full` nothing is cut, so a reader told only
630
+ // "over budget" would look for a removed rule and find none. In `kernel` the packing
631
+ // already ran and the projection the reader will see is the packed one — so a number
632
+ // over the line here means the KERNEL is over the line, which is a different remedy
633
+ // entirely: no amount of raising the number fixes a kernel that is too large on its own.
634
+ const kernel = mode === 'kernel';
635
+ return {
636
+ file: MANIFEST,
637
+ status: 'budget',
638
+ detail: kernel
639
+ ? `${bytes} bytes of always-on projection in kernel mode; this repository declared ${allowed}. The kernel itself is over the line: only hard and command rules were packed, so this is not a projection waiting to be trimmed`
640
+ : `${bytes} bytes of always-on projection; this repository declared ${allowed}. Nothing was cut: the projection is still in full mode, so every rule is loaded as written`,
641
+ remedy: kernel
642
+ ? `raise budget.bytes to ${bytes}, or move rules out of the hard and command classes into convention or style, which is the only way a kernel shrinks`
643
+ : `raise budget.bytes to ${bytes} if the projection is meant to be this size, switch mode to kernel to defer the convention and style rules, or narrow the rules that carry it`,
644
+ };
645
+ }
646
+
647
+ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
648
+
649
+ /**
650
+ * A manifest with this tool's own fields replaced by what this build writes, and
651
+ * nothing else touched.
652
+ *
653
+ * Spread from the repository's manifest rather than from `buildManifest`'s output:
654
+ * the builder fills a default for every key, so writing its result wholesale would
655
+ * overwrite `gates` and `limits` in any repository that chose its own. "Content the
656
+ * repo chose is never compared" is a promise about the repair too, not only about
657
+ * the report, and a repair is exactly where it would be broken.
658
+ *
659
+ * @param {object} manifest the repository's manifest, as parsed
660
+ * @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
661
+ * @returns {object} a new manifest; the argument is not mutated
662
+ */
663
+ export function withGenerated(manifest, expected) {
664
+ const patched = { ...manifest };
665
+ for (const key of GENERATED_KEYS) if (key in expected) patched[key] = expected[key];
666
+ return patched;
667
+ }
668
+
391
669
  /**
392
670
  * Is a generated field of this manifest wrong, or has a formatter taken its bytes?
393
671
  *
@@ -408,6 +686,12 @@ function defaultId(root) {
408
686
  * again. `upgrade` reported `current` the whole time, which is correct: the pin
409
687
  * was current. The bytes were not.
410
688
  *
689
+ * This one is a consequence of routing, not of adoption. Nothing reformatted the
690
+ * manifest until the pre-commit gate started running, so every repository that
691
+ * adopts the tool and runs a formatter arrives here on its first commit. The
692
+ * remedy is therefore "stop the formatter taking it, then `sync`" — and it was
693
+ * `init --yes`, which re-runs adoption to change one file's formatting (ADR-050).
694
+ *
411
695
  * Content the repo chose is never compared. A repo that edited `limits.coverage` on
412
696
  * purpose is not being told to revert it.
413
697
  *
@@ -417,20 +701,34 @@ export async function manifestDrift(root, stack, sha) {
417
701
  const { manifest, exists, error } = await readManifest(root);
418
702
  if (!exists || error !== null || manifest === null) return null;
419
703
 
420
- const expected = buildManifest({ root, stack, sha, existing: manifest });
421
- const stale = [];
422
- for (const key of Object.keys(expected)) {
423
- const generated =
424
- key === '$comment' ||
425
- (key === 'projection' && JSON.stringify(expected.projection) !== JSON.stringify(manifest.projection ?? null));
426
- if (generated && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key])) stale.push(key);
427
- }
704
+ const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
705
+ // `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
706
+ // fields are ours, and the answer is the set above. Iterating the expected object
707
+ // asks the same question of a different list and can only agree by coincidence.
708
+ const stale = [...GENERATED_KEYS].filter(
709
+ (key) => key in expected && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key]),
710
+ );
428
711
  if (stale.length > 0) {
429
712
  return {
430
713
  file: MANIFEST,
431
714
  status: 'stale-generated-field',
432
715
  detail: `generated field(s) ${stale.join(', ')} do not match what this simt-dev writes`,
433
- remedy: `run: ${CLI} init --yes (it refreshes the stamp without touching values this repo set)`,
716
+ // `sync`, and not `init`. `sync` repairs exactly this field set and nothing
717
+ // else; `init` re-runs adoption - it tags, it branches, it asks every question
718
+ // again - and two commands must not answer one question with different
719
+ // instructions (ADR-050).
720
+ //
721
+ // The remedy said `init` because for two releases nothing could repair a stale
722
+ // field, so the only command that could was the only one named. `sync` having
723
+ // nothing to repair while `check` refused anyway is the one-question-two-answers
724
+ // defect this repository exists to end, and it was true in all fourteen adopted
725
+ // repositories at the same moment: measured, not inferred. `check` exited 1 in
726
+ // every one of them while `sync` printed "already current; nothing written".
727
+ //
728
+ // No trailing parenthetical, because `compare` prints this string verbatim as
729
+ // the remedy on its own row. A remedy that reads well in one command's output
730
+ // and badly in another's is two answers wearing one string.
731
+ remedy: `run: ${CLI} sync --yes`,
434
732
  };
435
733
  }
436
734
 
@@ -446,10 +744,22 @@ export async function manifestDrift(root, stack, sha) {
446
744
  file: MANIFEST,
447
745
  status: 'reformatted',
448
746
  detail: 'content is what simt-dev would write, but the bytes differ - another formatter owns this file',
449
- remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI} init --yes`,
747
+ remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI} sync --yes`,
450
748
  };
451
749
  }
452
750
 
751
+ /**
752
+ * The bytes a manifest is written as.
753
+ *
754
+ * Exported because two callers now need them and one of them is a preview: a plan
755
+ * that reports a byte count it computed with a different serialiser than the writer
756
+ * used reports a number nothing will ever observe. `writeManifest` is the only place
757
+ * this format is spelled.
758
+ */
759
+ export function manifestText(manifest) {
760
+ return `${JSON.stringify(manifest, null, 2)}\n`;
761
+ }
762
+
453
763
  /** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
454
764
  export async function readManifest(root) {
455
765
  const path = await manifestPath(root);
@@ -473,6 +783,6 @@ export async function writeManifest(root, manifest) {
473
783
  const previous = join(root, MANIFEST_LEGACY);
474
784
  const fresh = join(root, MANIFEST);
475
785
  if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
476
- await writeFile(fresh, `${JSON.stringify(manifest, null, 2)}\n`);
786
+ await writeFile(fresh, manifestText(manifest));
477
787
  return fresh;
478
788
  }
@@ -24,7 +24,6 @@
24
24
 
25
25
  import { readFile, readdir } from 'node:fs/promises';
26
26
 
27
- import { frontmatterBlock } from './surfaces.mjs';
28
27
  import { dirname, join, resolve } from 'node:path';
29
28
  import { fileURLToPath } from 'node:url';
30
29
 
@@ -45,7 +44,9 @@ const MDC_DIR = join(ROOT, 'rules', 'mdc');
45
44
  * defect — which is the failure ADR-016 shipped once, a rule that reached no repo
46
45
  * because `applies` was absent, silently and with a test green.
47
46
  */
48
- const KNOWN_KEYS = new Set([
47
+ /** Frontmatter keys this tool reads, ADR-022. Exported so the one authority on the
48
+ * tool's own vocabulary is also the one a guard subtracts, rather than a second copy. */
49
+ export const KNOWN_KEYS = new Set([
49
50
  'simt-id',
50
51
  'simt-target',
51
52
  'simt-title',
@@ -300,14 +301,6 @@ function dropOrReplace(text, key, value) {
300
301
  return kept.join('\n').replace(/\n{3,}/g, '\n\n');
301
302
  }
302
303
 
303
- /** The frontmatter a projected `.mdc` keeps. `alwaysApply` and `description` are
304
- * read by the editor before any rule text, so they belong outside the region. */
305
- export function mdcFrontmatter(rule) {
306
- return frontmatterBlock([
307
- `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
308
- `alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
309
- ]);
310
- }
311
304
 
312
305
 
313
306
  /**
@@ -322,6 +315,4 @@ export function ruleName(rule) {
322
315
  if (typeof rule.target !== 'string' || rule.target === '') return null;
323
316
  const base = rule.target.slice(rule.target.lastIndexOf('/') + 1);
324
317
  return base.replace(/\.mdc$/, '');
325
- }
326
-
327
- export { MDC_DIR, parse as parseRegion };
318
+ }