simt-dev 0.1.1 → 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 (91) hide show
  1. package/CHANGELOG.md +209 -7
  2. package/README.md +32 -5
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  34. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  35. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  36. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  37. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  38. package/docs/decisions/README.md +20 -1
  39. package/package.json +1 -2
  40. package/rules/stacks/dart.md +38 -0
  41. package/rules/stacks/deno.md +36 -0
  42. package/rules/stacks/dotnet.md +35 -0
  43. package/rules/stacks/elixir.md +39 -0
  44. package/rules/stacks/go.md +34 -0
  45. package/rules/stacks/ruby.md +36 -0
  46. package/rules/stacks/rust.md +34 -0
  47. package/rules/stacks/swift.md +37 -0
  48. package/src/argv.mjs +35 -6
  49. package/src/ci/dependabot.mjs +1 -1
  50. package/src/ci/workflow.mjs +33 -11
  51. package/src/commands/graph.mjs +13 -4
  52. package/src/commands/help.mjs +22 -4
  53. package/src/commands/hooks.mjs +183 -67
  54. package/src/commands/index.mjs +53 -45
  55. package/src/commands/init.mjs +156 -14
  56. package/src/commands/rules.mjs +54 -3
  57. package/src/commands/skills.mjs +143 -15
  58. package/src/commands/skillsinstall.mjs +4 -4
  59. package/src/commands/sync.mjs +153 -11
  60. package/src/compare/discover.mjs +1 -1
  61. package/src/compare/hooksstate.mjs +96 -9
  62. package/src/compare/row.mjs +36 -7
  63. package/src/decisions/index.mjs +51 -12
  64. package/src/graph/load.mjs +126 -15
  65. package/src/hooks/capability.mjs +49 -5
  66. package/src/hooks/dispatcher.mjs +51 -6
  67. package/src/hooks/gates.mjs +1 -3
  68. package/src/hooks/resolve.mjs +24 -25
  69. package/src/hooks/shim.mjs +29 -5
  70. package/src/hooks/where.mjs +1 -1
  71. package/src/identity.mjs +207 -0
  72. package/src/mcp/tools.mjs +2 -2
  73. package/src/output/index.mjs +8 -7
  74. package/src/package-version.mjs +74 -0
  75. package/src/projection/baselines.mjs +1 -1
  76. package/src/projection/conflicts.mjs +57 -6
  77. package/src/projection/manifest.mjs +248 -26
  78. package/src/projection/markers.mjs +3 -3
  79. package/src/projection/mdc.mjs +6 -15
  80. package/src/projection/project.mjs +1 -1
  81. package/src/projection/rules.mjs +21 -8
  82. package/src/projection/surfaces.mjs +2 -2
  83. package/src/projection/targets.mjs +53 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +4 -3
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +2 -2
  89. package/src/tsconfig.mjs +1 -1
  90. package/src/workspace.mjs +0 -9
  91. 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,
@@ -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
  *
@@ -397,7 +587,7 @@ function defaultId(root) {
397
587
  * 1. A STALE GENERATED FIELD. `buildManifest` emits `$comment` and
398
588
  * `projection.simtDevSha`; the repo owns everything else. `upgrade` spreads the
399
589
  * existing manifest and refreshes only the SHA, so a field added to the writer
400
- * after a repo was adopted never appears — simt-space had been projecting for
590
+ * after a repo was adopted never appears — one repository had been projecting for
401
591
  * several versions with no `surfaces` key, and nothing said so. Every parse-based
402
592
  * comparison passes, because the file is valid and the values in it are all true.
403
593
  *
@@ -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
  }
@@ -217,7 +217,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
217
217
  );
218
218
  const trimmed = body.replace(/\n+$/, '');
219
219
  // Two lines here exist only because a real repo proved they are needed. A
220
- // formatter that runs on commit - simt-space has one, and three of the estate's
220
+ // formatter that runs on commit - several repositories have one, and three of the
221
221
  // other repos do too - rewrote the region on the very first commit: it inserted a
222
222
  // blank line after the header comment and added the trailing newline. Both edits
223
223
  // are things prettier considers correct, and either one alone makes `check`
@@ -226,7 +226,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
226
226
  // projection emits the formatter's shape in the first place. The blank line before
227
227
  // END is the same class: prettier treats an HTML comment after a paragraph as a
228
228
  // new block and inserts the separating blank line itself, which made
229
- // simt-blog's `format:check` fail on a file whose generated region was correct.
229
+ // a `format:check` fail on a file whose generated region was correct.
230
230
  const out =
231
231
  trimmed === ''
232
232
  ? [s.begin, ...header, s.end]
@@ -293,7 +293,7 @@ export function inject(text, body, meta, opts = {}) {
293
293
  // terminates the region with one; adding a second left every adopted file
294
294
  // ending in a blank line. Nothing in this tool noticed, because `check`
295
295
  // compares region bodies and the byte after END is not part of the region —
296
- // but bunnyland-server's own `git diff --check` gate did, and it is a gate
296
+ // but a repository's own `git diff --check` gate did, and it is a gate
297
297
  // that repo runs before every commit. The `updated` branch below has always
298
298
  // been correct for exactly this reason.
299
299
  //
@@ -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',
@@ -201,7 +202,7 @@ function stripFrontmatter(text) {
201
202
  *
202
203
  * An empty-string default is a deliberate "this repo has nothing to add here"
203
204
  * and the line it sits on is DROPPED. That is how `preview-postflight` reaches
204
- * `simt-social-media` without printing an empty bullet, and how a repo with no
205
+ * a repository without printing an empty bullet, and how a repo with no
205
206
  * production-domain note gets a clean document instead of a blank one.
206
207
  *
207
208
  * A placeholder with neither a declared nor a default value is a defect: the
@@ -218,7 +219,7 @@ export function renderMdc(rule, declared = {}) {
218
219
 
219
220
  if (has && provided === '') {
220
221
  // A repo declared this key and declared it empty: the line it sits on is
221
- // removed. This is how preview-postflight reaches simt-social-media without
222
+ // removed. This is how preview-postflight reaches a repository without
222
223
  // printing a blank bullet.
223
224
  body = dropOrReplace(body, sub.key, '');
224
225
  continue;
@@ -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);
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // 1. A POINTER IS NOT A COPY. `CLAUDE.md` and `GEMINI.md` are symlinks to
19
19
  // `AGENTS.md`. A copy is the corpus's one true content duplicate
20
- // (bunnyland-server's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
20
+ // (one repository's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
21
21
  // the DeepSeek harness explicitly rejects loading both in one directory
22
22
  // because "repositories in transition commonly duplicate guidance" — and
23
23
  // *its* dedup is content-based, so a symlink collapses to one while a
@@ -32,7 +32,7 @@
32
32
  // mode bit. So those get machine-written copies — and because every byte is
33
33
  // derived from `rules/mdc/*.mdc` and `simt-dev check` compares each against
34
34
  // that same derivation, they are not duplication, they are a build output.
35
- // The difference from bunnyland's duplicate is that nobody can hand-edit one
35
+ // The difference from that duplicate is that nobody can hand-edit one
36
36
  // and have it survive a sync.
37
37
  //
38
38
  // 3. AN ALWAYS-ON RULE EMITS NO SCOPING FRONTMATTER. Claude Code's `paths:` key
@@ -64,14 +64,45 @@ export async function ruleTargets(root, rule, body, sha, resolved, notes) {
64
64
  }
65
65
 
66
66
  const mine = s.ruleFrontmatter(rule);
67
- const frontmatter = extractFrontmatter(existing) ?? frontmatterBlock(mine);
67
+ // The frontmatter is re-derived, never inherited (S16).
68
+ //
69
+ // Two things were wrong and only the second was visible. The first was
70
+ // `extractFrontmatter(existing) ?? frontmatterBlock(mine)`, where the file's own
71
+ // block won whenever the file already had one. The second was this line's
72
+ // neighbour: when the existing region parsed cleanly, `composed` was the whole
73
+ // existing file, so `inject` replaced the region between the markers and left the
74
+ // frontmatter above them exactly as it found it — whatever this tool emitted the
75
+ // first time it saw this surface, forever. A surface that adds a key, or whose
76
+ // `alwaysApply` flips, could never reach a repository that had already adopted it,
77
+ // and no gate could report it: the file agrees with itself, so nothing is stale by
78
+ // any comparison the tool makes.
79
+ //
80
+ // One asymmetry, deliberately: a surface that emits NO frontmatter leaves whatever
81
+ // is there alone. This tool owns the block it writes and will replace it; deleting a
82
+ // block it did not write is a different operation, and a surface with no frontmatter
83
+ // of its own has no opinion about it.
84
+ const frontmatter = mine.length > 0 ? frontmatterBlock(mine) : extractFrontmatter(existing) ?? '';
68
85
  const state = exists ? parse(existing) : { status: 'absent' };
69
86
  // A surface that wants no frontmatter must not get a blank line where the
70
87
  // frontmatter would have been: Claude's rule files opened with two empty lines
71
88
  // before the marker, which is untidy in a file the reader will open, and a
72
89
  // difference between surfaces that no check reported.
90
+ //
91
+ // Swapped in place rather than stripped and rejoined. My first attempt here was
92
+ // `${frontmatter}\n${bodyAfterFrontmatter(existing)}`, which looked equivalent and
93
+ // was not: that helper's trailing `\n*` swallows the blank line between `---` and
94
+ // the region, so the reconstruction was one newline short of the file and `inject`
95
+ // reported `updated` on every run forever while writing identical bytes. The file
96
+ // was right; the verdict never settled, so `check` reported drift no reader could
97
+ // see and `sync` rewrote a correct file on every invocation. Anything that rebuilds
98
+ // a file has to reproduce the bytes it did not mean to touch, and the cheapest way
99
+ // to guarantee that is not to rebuild them.
73
100
  const composed =
74
- state.status === 'ok' ? existing : frontmatter === '' ? '' : `${frontmatter}\n`;
101
+ state.status === 'ok'
102
+ ? swapFrontmatter(existing, frontmatter)
103
+ : frontmatter === ''
104
+ ? ''
105
+ : `${frontmatter}\n`;
75
106
  const result = inject(composed, body, { sha, sources: [rule.source] });
76
107
 
77
108
  // Report the prose being retired rather than letting it vanish into a diff the
@@ -201,11 +232,6 @@ export async function pointerState(root, pointer) {
201
232
  return { file: pointer.file, state: 'mispointed', link, detail: `points at ${link}` };
202
233
  }
203
234
 
204
- export function surfaceFrontmatter(surfaceId, rule) {
205
- const s = SURFACES.find((x) => x.id === surfaceId);
206
- return s && s.ruleFrontmatter ? s.ruleFrontmatter(rule) : [];
207
- }
208
-
209
235
  // The per-surface path lives in surfaces.mjs next to the row that justifies it;
210
236
  // re-exported so a caller building a target never has to know which module owns
211
237
  // path construction as opposed to frontmatter construction.
@@ -216,6 +242,26 @@ export function extractFrontmatter(text) {
216
242
  return m ? m[0].trimEnd() : null;
217
243
  }
218
244
 
245
+ /**
246
+ * Put `frontmatter` where a frontmatter block already is, and leave every other byte
247
+ * exactly where it was.
248
+ *
249
+ * The block match deliberately stops at the closing `---` and not past it, so the
250
+ * replacement starts at the newline that follows — the blank line a formatter requires
251
+ * before an HTML comment survives belongs to the file, not to the block, and consuming
252
+ * it here is what made `inject` report `updated` forever on files whose bytes never
253
+ * changed.
254
+ *
255
+ * A file with no block gets one above its content. The separator below it is left to
256
+ * `inject`'s `seam`, which is the one place that knows what the surrounding document
257
+ * format requires.
258
+ */
259
+ function swapFrontmatter(text, frontmatter) {
260
+ if (frontmatter === '') return text;
261
+ const m = /^---\n[\s\S]*?\n---/.exec(text);
262
+ return m === null ? `${frontmatter}\n${text}` : `${frontmatter}${text.slice(m[0].length)}`;
263
+ }
264
+
219
265
  function bodyAfterFrontmatter(text) {
220
266
  return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
221
267
  }