simt-dev 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +173 -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/README.md +17 -0
  10. package/package.json +1 -2
  11. package/rules/stacks/dart.md +38 -0
  12. package/rules/stacks/deno.md +36 -0
  13. package/rules/stacks/dotnet.md +35 -0
  14. package/rules/stacks/elixir.md +39 -0
  15. package/rules/stacks/go.md +34 -0
  16. package/rules/stacks/ruby.md +36 -0
  17. package/rules/stacks/rust.md +34 -0
  18. package/rules/stacks/swift.md +37 -0
  19. package/src/argv.mjs +35 -6
  20. package/src/ci/dependabot.mjs +1 -1
  21. package/src/ci/workflow.mjs +33 -11
  22. package/src/commands/graph.mjs +13 -4
  23. package/src/commands/help.mjs +22 -4
  24. package/src/commands/hooks.mjs +173 -57
  25. package/src/commands/index.mjs +53 -45
  26. package/src/commands/init.mjs +89 -13
  27. package/src/commands/rules.mjs +54 -3
  28. package/src/commands/skills.mjs +143 -15
  29. package/src/commands/skillsinstall.mjs +3 -3
  30. package/src/commands/sync.mjs +153 -11
  31. package/src/compare/hooksstate.mjs +96 -9
  32. package/src/compare/row.mjs +34 -5
  33. package/src/decisions/index.mjs +21 -6
  34. package/src/graph/load.mjs +125 -14
  35. package/src/hooks/capability.mjs +48 -4
  36. package/src/hooks/dispatcher.mjs +50 -5
  37. package/src/hooks/gates.mjs +0 -2
  38. package/src/hooks/resolve.mjs +24 -25
  39. package/src/hooks/shim.mjs +29 -5
  40. package/src/identity.mjs +0 -11
  41. package/src/mcp/tools.mjs +2 -2
  42. package/src/output/index.mjs +8 -7
  43. package/src/package-version.mjs +74 -0
  44. package/src/projection/conflicts.mjs +56 -5
  45. package/src/projection/manifest.mjs +247 -25
  46. package/src/projection/mdc.mjs +4 -13
  47. package/src/projection/project.mjs +1 -1
  48. package/src/projection/rules.mjs +21 -8
  49. package/src/projection/targets.mjs +53 -7
  50. package/src/resolve/eval.mjs +64 -0
  51. package/src/resolve/fingerprint.mjs +1 -0
  52. package/src/resolve/near.mjs +26 -7
  53. package/src/resolve/resolve.mjs +1 -1
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/workspace.mjs +0 -9
  56. package/graph/skills.graph.json +0 -1622
@@ -11,6 +11,8 @@
11
11
  // substring scan reports that file as violating the very rule it is correctly
12
12
  // restating. So every match is checked for prohibition context first.
13
13
 
14
+ import { POINTERS, SURFACES } from './surfaces.mjs';
15
+
14
16
  /** Files whose text is a hand-written rule surface. Scanned for conflicts only;
15
17
  * generated regions are excluded at read time by the caller.
16
18
  *
@@ -18,11 +20,60 @@
18
20
  * worth recording: the estate's three always-on `.mdc` families were the single
19
21
  * worst-drifted content in it, and `simt-dev init` reported `CONFLICTS none detected`
20
22
  * on the repo holding them. A conflict detector that only looks at the filenames
21
- * it was written for will quietly miss a whole rule surface added later. */
23
+ * it was written for will quietly miss a whole rule surface added later.
24
+ *
25
+ * And then it did. The comment above was written as a lesson and the lesson was not
26
+ * taken: the list stayed hand-written, two surfaces were added to `SURFACES`
27
+ * afterwards, and `.claude/rules/` and `.agents/rules/` were silently unscanned —
28
+ * two of the five surfaces, and both of them read a rule that this tool writes. A
29
+ * comment saying "derive this" is not deriving it. So the per-rule directories come
30
+ * from `SURFACES` now (see `ruleGlobs`), and this list holds only the files no
31
+ * surface declares. */
22
32
  export const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md'];
23
33
 
24
- /** Rule files discovered by glob rather than by name. */
25
- export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
34
+ /** Pointer targets, scanned because this tool writes them and they are rule files.
35
+ *
36
+ * `CLAUDE.md` was named by hand in `RULE_FILES` for the whole life of the tool, and
37
+ * `GEMINI.md` — which `POINTERS` creates for a repo on the Antigravity surface — was
38
+ * not. So one file this tool writes was scanned and the other was not, with nothing
39
+ * in the source saying which was which: the difference was an accident of the order
40
+ * the two were added. Derived from `POINTERS` now, so it cannot recur. */
41
+ function pointerFiles() {
42
+ return POINTERS.map((p) => p.path);
43
+ }
44
+
45
+ /**
46
+ * The per-rule directories, read out of `SURFACES` rather than written out here.
47
+ *
48
+ * One list of surfaces, read by everything that has to know where a surface keeps its
49
+ * rules. A surface added to `SURFACES` is now scanned for conflicts the moment it
50
+ * exists, which is the property the hand-written list kept failing to have — and it
51
+ * could only fail quietly, because a detector that matches nothing reports the same
52
+ * answer as a repository with no rules.
53
+ *
54
+ * `CLAUDE.md` and `GEMINI.md` are here through `RULE_FILES`/`POINTERS` rather than
55
+ * through this: they are symlinks to the primary region, so they are scanned as
56
+ * *files*, and `CONTRIBUTING.md` belongs to no surface at all.
57
+ *
58
+ * Takes the list as an argument rather than closing over it so the derivation can be
59
+ * shown on a surface that does not exist. The default is `SURFACES`, so every caller
60
+ * still reads the one authority; there is no way to pass anything else in production.
61
+ *
62
+ * @param {{rule?: string}[]} [surfaces]
63
+ */
64
+ export function ruleGlobs(surfaces = SURFACES) {
65
+ return surfaces.filter((s) => typeof s.rule === 'string').map((s) => {
66
+ const slash = s.rule.lastIndexOf('/');
67
+ const extension = s.rule.slice(s.rule.lastIndexOf('.'));
68
+ // The FINAL extension, not the whole tail after `{name}`. Copilot's template is
69
+ // `{name}.instructions.md`, and matching that whole suffix would be narrower than
70
+ // the `.md` this file used to list by hand — which is the difference between seeing
71
+ // a hand-written `reviewer.md` in that directory and reporting the directory as
72
+ // empty. A file this tool writes always carries its own suffix; a file a person
73
+ // wrote in the same directory need not, and the person is who this scanner is for.
74
+ return `${s.rule.slice(0, slash)}/*${extension}`;
75
+ });
76
+ }
26
77
 
27
78
  /** Every hand-written rule path in a repo, named or globbed.
28
79
  *
@@ -36,8 +87,8 @@ export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
36
87
  export async function rulePaths(root) {
37
88
  const { readdir } = await import('node:fs/promises');
38
89
  const { join } = await import('node:path');
39
- const out = RULE_FILES.map((f) => join(root, f));
40
- for (const glob of RULE_GLOBS) {
90
+ const out = [...new Set([...RULE_FILES, ...pointerFiles()])].map((f) => join(root, f));
91
+ for (const glob of ruleGlobs()) {
41
92
  const star = glob.lastIndexOf('*');
42
93
  const dir = join(root, glob.slice(0, star));
43
94
  const tail = glob.slice(star + 1);
@@ -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,
@@ -303,7 +443,12 @@ export function buildManifest({ root, stack = [], sha, existing = null, capabili
303
443
  // because a typo here silently reduces the estate to AGENTS.md, which looks
304
444
  // exactly like a working setup.
305
445
  surfaces: surfaces ?? existing?.surfaces ?? null,
306
- projection: { simtDevSha: sha ?? null },
446
+ // simtDevSha is the digest of the rule bytes this projection was rendered from;
447
+ // version is the simt-dev release that produced it. The gate compares the running
448
+ // binary to version and refuses on a mismatch, which is how a repository without
449
+ // npm (a global install, pinned only by this field) is held to the release it
450
+ // adopted. See src/package-version.mjs.
451
+ projection: { simtDevSha: sha ?? null, version: version ?? null },
307
452
  };
308
453
  }
309
454
 
@@ -348,18 +493,30 @@ function defaultLimits() {
348
493
  }
349
494
 
350
495
  /** 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);
496
+ function defaultGates(stack, facts = []) {
497
+ // Tags and facts are one signal set for gate purposes: a row in `GATES_BY_STACK`
498
+ // keys on a tag, a row in `ECOSYSTEMS` on a fact, and the two never name each
499
+ // other's vocabulary (facts are `has-*`, tags are not), so one union cannot
500
+ // cross-match.
501
+ const signals = new Set([...stack, ...facts]);
353
502
  const gates = { preCommit: [], prePush: [] };
354
503
 
355
504
  for (const row of GATES_BY_STACK) {
356
- if (row.when.some((tag) => tags.has(tag))) {
505
+ if (row.when.some((tag) => signals.has(tag))) {
357
506
  gates.preCommit.push(...row.preCommit);
358
507
  gates.prePush.push(...row.prePush);
359
508
  }
360
509
  }
510
+ for (const row of ECOSYSTEMS) {
511
+ if (signals.has(row.fact)) {
512
+ // A row proposes what it has: the Maven row has no preCommit gates, so a
513
+ // missing key is "none", not a defect. Spreading undefined throws.
514
+ gates.preCommit.push(...(row.preCommit ?? []));
515
+ gates.prePush.push(...(row.prePush ?? []));
516
+ }
517
+ }
361
518
  for (const row of TAG_GATES) {
362
- if (tags.has(row.tag)) gates.preCommit.push(...row.preCommit);
519
+ if (signals.has(row.tag)) gates.preCommit.push(...row.preCommit);
363
520
  }
364
521
 
365
522
  // A repo with no recognised stack still gets a gate list, because a gate that
@@ -388,6 +545,39 @@ function defaultId(root) {
388
545
  return name === '' || name === '.' ? 'unnamed-repo' : name;
389
546
  }
390
547
 
548
+ /**
549
+ * The fields this tool writes. Everything else in a manifest belongs to the
550
+ * repository that adopted the tool.
551
+ *
552
+ * It is a value rather than an expression inside `manifestDrift` because the question
553
+ * "is this field ours?" now has two callers that must answer it identically: the gate
554
+ * that reports one stale, and `sync`, which repairs exactly the fields the gate names.
555
+ * A second implementation of this set is a repair that fixes a field nothing reports,
556
+ * or a report about a field nothing repairs - and each half passes its own test,
557
+ * which is ADR-045's shape reached from the other direction.
558
+ */
559
+ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
560
+
561
+ /**
562
+ * A manifest with this tool's own fields replaced by what this build writes, and
563
+ * nothing else touched.
564
+ *
565
+ * Spread from the repository's manifest rather than from `buildManifest`'s output:
566
+ * the builder fills a default for every key, so writing its result wholesale would
567
+ * overwrite `gates` and `limits` in any repository that chose its own. "Content the
568
+ * repo chose is never compared" is a promise about the repair too, not only about
569
+ * the report, and a repair is exactly where it would be broken.
570
+ *
571
+ * @param {object} manifest the repository's manifest, as parsed
572
+ * @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
573
+ * @returns {object} a new manifest; the argument is not mutated
574
+ */
575
+ export function withGenerated(manifest, expected) {
576
+ const patched = { ...manifest };
577
+ for (const key of GENERATED_KEYS) if (key in expected) patched[key] = expected[key];
578
+ return patched;
579
+ }
580
+
391
581
  /**
392
582
  * Is a generated field of this manifest wrong, or has a formatter taken its bytes?
393
583
  *
@@ -408,6 +598,12 @@ function defaultId(root) {
408
598
  * again. `upgrade` reported `current` the whole time, which is correct: the pin
409
599
  * was current. The bytes were not.
410
600
  *
601
+ * This one is a consequence of routing, not of adoption. Nothing reformatted the
602
+ * manifest until the pre-commit gate started running, so every repository that
603
+ * adopts the tool and runs a formatter arrives here on its first commit. The
604
+ * remedy is therefore "stop the formatter taking it, then `sync`" — and it was
605
+ * `init --yes`, which re-runs adoption to change one file's formatting (ADR-050).
606
+ *
411
607
  * Content the repo chose is never compared. A repo that edited `limits.coverage` on
412
608
  * purpose is not being told to revert it.
413
609
  *
@@ -417,20 +613,34 @@ export async function manifestDrift(root, stack, sha) {
417
613
  const { manifest, exists, error } = await readManifest(root);
418
614
  if (!exists || error !== null || manifest === null) return null;
419
615
 
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
- }
616
+ const expected = buildManifest({ root, stack, sha, version: packageVersion(), existing: manifest });
617
+ // `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
618
+ // fields are ours, and the answer is the set above. Iterating the expected object
619
+ // asks the same question of a different list and can only agree by coincidence.
620
+ const stale = [...GENERATED_KEYS].filter(
621
+ (key) => key in expected && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key]),
622
+ );
428
623
  if (stale.length > 0) {
429
624
  return {
430
625
  file: MANIFEST,
431
626
  status: 'stale-generated-field',
432
627
  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)`,
628
+ // `sync`, and not `init`. `sync` repairs exactly this field set and nothing
629
+ // else; `init` re-runs adoption - it tags, it branches, it asks every question
630
+ // again - and two commands must not answer one question with different
631
+ // instructions (ADR-050).
632
+ //
633
+ // The remedy said `init` because for two releases nothing could repair a stale
634
+ // field, so the only command that could was the only one named. `sync` having
635
+ // nothing to repair while `check` refused anyway is the one-question-two-answers
636
+ // defect this repository exists to end, and it was true in all fourteen adopted
637
+ // repositories at the same moment: measured, not inferred. `check` exited 1 in
638
+ // every one of them while `sync` printed "already current; nothing written".
639
+ //
640
+ // No trailing parenthetical, because `compare` prints this string verbatim as
641
+ // the remedy on its own row. A remedy that reads well in one command's output
642
+ // and badly in another's is two answers wearing one string.
643
+ remedy: `run: ${CLI} sync --yes`,
434
644
  };
435
645
  }
436
646
 
@@ -446,10 +656,22 @@ export async function manifestDrift(root, stack, sha) {
446
656
  file: MANIFEST,
447
657
  status: 'reformatted',
448
658
  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`,
659
+ remedy: `add ${MANIFEST_FILE} to .prettierignore (a generated file is not prose), then: ${CLI} sync --yes`,
450
660
  };
451
661
  }
452
662
 
663
+ /**
664
+ * The bytes a manifest is written as.
665
+ *
666
+ * Exported because two callers now need them and one of them is a preview: a plan
667
+ * that reports a byte count it computed with a different serialiser than the writer
668
+ * used reports a number nothing will ever observe. `writeManifest` is the only place
669
+ * this format is spelled.
670
+ */
671
+ export function manifestText(manifest) {
672
+ return `${JSON.stringify(manifest, null, 2)}\n`;
673
+ }
674
+
453
675
  /** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
454
676
  export async function readManifest(root) {
455
677
  const path = await manifestPath(root);
@@ -473,6 +695,6 @@ export async function writeManifest(root, manifest) {
473
695
  const previous = join(root, MANIFEST_LEGACY);
474
696
  const fresh = join(root, MANIFEST);
475
697
  if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
476
- await writeFile(fresh, `${JSON.stringify(manifest, null, 2)}\n`);
698
+ await writeFile(fresh, manifestText(manifest));
477
699
  return fresh;
478
700
  }
@@ -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
+ }
@@ -478,7 +478,7 @@ async function regionOpts(root) {
478
478
  * A reader that stops at a byte offset never sees a region that ends past it.
479
479
  * The finding names the reader, because "the file is long" does not say who stops.
480
480
  */
481
- export function readLimitFindings(text, file) {
481
+ function readLimitFindings(text, file) {
482
482
  const state = parse(text);
483
483
  if (state.status !== 'ok' || typeof state.endByte !== 'number') return [];
484
484
  const findings = [];
@@ -57,13 +57,15 @@ const RULES_DIR = join(ROOT, 'rules');
57
57
  * unrecoverably. That is the failure this replaces (ADR-032).
58
58
  *
59
59
  * 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
60
+ * set is `rules/` on disk plus the RENDERED baseline bodies: the shared
61
61
  * `.editorconfig` and `.gitattributes` blocks are strings a repository receives, so by
62
62
  * 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.
63
+ * happens to live in, and a body defined in `src/` is a source like any other. The
64
+ * registry (`graph/`) is workspace data the package no longer ships (ADR-061), so it is
65
+ * not part of this set even where it exists on disk.
64
66
  *
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
67
+ * It deliberately excludes `src/` as a directory. A refactor that changes no rule and
68
+ * no baseline body produces byte-identical output, and stamping those
67
69
  * consumers would report drift where there is none - which is the estate's own rule that
68
70
  * a report must be a fact.
69
71
  *
@@ -93,7 +95,7 @@ export async function provenanceDigest() {
93
95
  */
94
96
  export async function provenanceInputs() {
95
97
  const inputs = [];
96
- for (const dir of ['rules', 'graph']) {
98
+ for (const dir of ['rules']) {
97
99
  for (const rel of await walk(join(ROOT, dir))) {
98
100
  inputs.push({ path: `${dir}/${rel}`, bytes: await readFile(join(ROOT, dir, rel)) });
99
101
  }
@@ -125,7 +127,8 @@ export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
125
127
  * one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
126
128
  * as an editor's instruction — Cursor, Copilot and Antigravity all parse a
127
129
  * frontmatter block the same way — and would be silently inert. */
128
- const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
130
+ /** 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']);
129
132
 
130
133
  /** Report the first thing wrong with a profile's frontmatter, in the order a reader
131
134
  * fixes it: no id to select it by, then a key that will not be read, then a tag the
@@ -293,8 +296,18 @@ async function ruleBodies(root, manifest, flags) {
293
296
 
294
297
  const declared = await readDeclared(root);
295
298
 
299
+ // Deliberately NOT folded in. This array becomes `mdc.problems`, and `sync` reports it
300
+ // as "canonical .mdc rule(s) cannot be projected" with the remedy "fix the reported
301
+ // substitution keys in simt.json". A surface typo is not a rule problem and has no
302
+ // substitution keys — `surfaces: ["curosr"]` was reported to a reader as though they
303
+ // had misspelled a key in a block they had never heard of, and the only way to find the
304
+ // real cause was to run `check`, which already said it plainly. Two surfaces report the
305
+ // same problem; only one of them names it correctly, and the one that mislabels it is
306
+ // the one the writer uses.
307
+ //
308
+ // Nothing is lost by dropping it here: `plan` returns `surfaceProblems` from this same
309
+ // `resolveSurfaces` call, and `checkTargets` reads it independently at project.mjs.
296
310
  const resolved = resolveSurfaces(manifest, flags ?? {});
297
- for (const p of resolved.problems) problems.push(p);
298
311
 
299
312
  // A manifest key that no shipped rule declares is a typo, or a leftover from a rule
300
313
  // that was renamed. Both read as "configured" in the one file a reviewer opens to
@@ -574,7 +587,7 @@ export function render(selected) {
574
587
  * so it lands directly under `## Stack: …` instead of under whatever section the
575
588
  * previous source ended on.
576
589
  */
577
- export function frameSource(rule, body, kind) {
590
+ function frameSource(rule, body, kind) {
578
591
  const text = demoteHeadings(body).trim();
579
592
  if (!text) return '';
580
593
  const title = sourceTitle(rule, kind);