simt-dev 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -18,11 +18,15 @@
18
18
  import { readFile, rename, writeFile } from 'node:fs/promises';
19
19
  import { existsSync } from 'node:fs';
20
20
  import { basename, join, resolve } from 'node:path';
21
+ import { journaledWriteFile } from '../journal.mjs';
21
22
  // gates.mjs imports nothing, so this direction cannot cycle. It is the gate table
22
23
  // this file already selects from; duplicating the runnability rules here would be two
23
24
  // answers to "can this repo run that gate", which is precisely the kind of drift this
24
25
  // file exists to prevent elsewhere.
25
26
  import { getGate } from '../hooks/gates.mjs';
27
+ // Q31: the one Node runner table. This module and the card, capability,
28
+ // dispatcher and CI workflow all spell `<manager> run <script>` from it.
29
+ import { runScript } from '../ecosystem/node-runner.mjs';
26
30
 
27
31
  // Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
28
32
  // and the manifest name is used a dozen times below in remedy strings that name this
@@ -200,7 +204,7 @@ export const ECOSYSTEMS = [
200
204
  * fail closed — the defect is declaring a gate this repo has no way to run.
201
205
  *
202
206
  * The check mirrors what the dispatcher actually attempts:
203
- * - `script` gates run `npm run <script>`, so the script must exist
207
+ * - `script` gates run `<manager> run <script>`, so the script must exist
204
208
  * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
205
209
  * installed, not merely declared as a dependency
206
210
  * - `command` gates run whatever the manifest's `gates.commands` declares, so a
@@ -225,18 +229,27 @@ export function gateRunnable(gate, capability = {}) {
225
229
  : { runnable: true, why: `runs: ${declared}` };
226
230
  }
227
231
 
232
+ if (gate.kind === 'builtin') {
233
+ // The one kind with no external requirement: the implementation ships inside
234
+ // the binary that is reading this manifest (ADR-100). Saying otherwise would
235
+ // make `check` report a gate as unrunnable that runs on every commit.
236
+ return { runnable: true, why: 'runs inside the tool itself; nothing to install' };
237
+ }
228
238
  if (gate.kind === 'command') {
229
239
  return { runnable: false, why: `no command is declared for it in gates.commands` };
230
240
  }
231
241
  if (gate.kind === 'script') {
232
242
  const script = gate.script ?? gate.name;
233
- if (capability.scripts?.[script]) return { runnable: true, why: `runs: npm run ${script}` };
243
+ // Q31: the runnable sentence names the repo's own runner, never a bare npm
244
+ // assumption — the card, the dispatcher and this check read one resolver.
245
+ const spell = runScript(capability.runner ?? 'npm', script);
246
+ if (capability.scripts?.[script]) return { runnable: true, why: `runs: ${spell}` };
234
247
  // Saying "no script in package.json" about a repo with no package.json states a
235
248
  // fact about a file that does not exist and implies a fix that cannot be taken.
236
249
  return {
237
250
  runnable: false,
238
251
  why: foreign
239
- ? `this repo runs ${runnerName(capability.runner)}, not npm, so "npm run ${script}" cannot run here`
252
+ ? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
240
253
  : `no "${script}" script in package.json`,
241
254
  };
242
255
  }
@@ -270,11 +283,17 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
270
283
  const name = gate?.name ?? 'the gate';
271
284
  const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
272
285
  const foreign = isForeignRunner(runner);
286
+ // Q31: a missing tool in a Node repo names the repo's own installer —
287
+ // `pnpm add -D`, not `npm i -D` — and anything elsewhere keeps the honest
288
+ // undo: name the program under `gates.commands`.
289
+ const addDev = { npm: 'npm i -D', pnpm: 'pnpm add -D', yarn: 'yarn add -D', bun: 'bun add -d' }[runner] ?? null;
273
290
  const fix =
274
291
  npmShaped && foreign
275
292
  ? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
276
293
  : gate?.kind === 'staged'
277
- ? `install ${gate.tools.join(', ')} as a dev dependency`
294
+ ? addDev
295
+ ? `install ${gate.tools.join(', ')} as a dev dependency: ${addDev} ${gate.tools.join(' ')} (the gate runs from the pinned dependency)`
296
+ : `install ${gate.tools.join(', ')} as a dev dependency`
278
297
  : gate?.kind === 'script'
279
298
  ? `add a "${gate.script ?? name}" script to package.json`
280
299
  : gate?.kind === 'command'
@@ -295,11 +314,14 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
295
314
  /**
296
315
  * Is this runner one where `npm run` is not how a gate is spelled?
297
316
  *
298
- * `undefined` means "the caller did not report a runner", and `npm` means the npm
299
- * remedy applies. Only a positively identified other ecosystem changes the wording.
317
+ * `undefined` means "the caller did not report a runner", and every Node manager
318
+ * (`npm`, `pnpm`, `yarn`, `bun`) spells gates as `<manager> run <script>` with
319
+ * scripts living in the same `package.json`. Only a positively identified
320
+ * non-Node runner changes the wording. (Q31: pnpm is Node-shaped, not foreign.)
300
321
  */
301
322
  function isForeignRunner(runner) {
302
- return runner != null && runner !== 'npm';
323
+ if (runner == null) return false;
324
+ return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
303
325
  }
304
326
 
305
327
  /** How a reader would name the runner in a sentence. */
@@ -309,6 +331,9 @@ function runnerName(runner) {
309
331
  none: 'no recognised runner',
310
332
  gradle: 'Gradle',
311
333
  python: 'Python',
334
+ npm: 'npm',
335
+ pnpm: 'pnpm',
336
+ yarn: 'yarn',
312
337
  bun: 'bun',
313
338
  cargo: 'Cargo',
314
339
  go: 'Go',
@@ -376,14 +401,15 @@ export function planGates(stack = [], capability = null, facts = []) {
376
401
  */
377
402
  function ecosystemCommands(facts = [], capability = null) {
378
403
  if (!capability?.tools?.length) return {};
379
- // An npm runner already answers "how do I test and format" with its own package.json
380
- // scripts, and a seed only wins over those scripts when the repo has not declared a
381
- // command of its own — which is exactly the mixed repos (package.json AND Cargo.toml)
382
- // where the script is the deliberate answer. Seeding there would replace a choice with
383
- // a default; the reader's escape hatch is declaring gates.commands by hand, which a
384
- // seed would lose to anyway. Pure ecosystem repos have runner 'cargo'/'go'/... and
385
- // seed normally.
386
- if (capability.runner === 'npm') return {};
404
+ // A Node runner (npm/pnpm/yarn/bun) already answers "how do I test and format"
405
+ // with its own package.json scripts, and a seed only wins over those scripts when
406
+ // the repo has not declared a command of its own — which is exactly the mixed
407
+ // repos (package.json AND Cargo.toml) where the script is the deliberate answer.
408
+ // Seeding there would replace a choice with a default; the reader's escape hatch
409
+ // is declaring gates.commands by hand, which a seed would lose to anyway. Pure
410
+ // ecosystem repos have runner 'cargo'/'go'/... and seed normally.
411
+ // Q31: every Node manager skips seeding — pnpm is not "foreign", it is Node.
412
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(capability.runner)) return {};
387
413
  const present = new Set(facts);
388
414
  const commands = {};
389
415
  for (const eco of ECOSYSTEMS) {
@@ -721,6 +747,23 @@ export function declaredShared(manifest) {
721
747
 
722
748
  export const GENERATED_KEYS = new Set(['$comment', 'projection']);
723
749
 
750
+ // The sub-keys of `projection` this tool writes. `projection` is a generated key, but
751
+ // not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
752
+ // one that shipped), and a repair that replaces the object deletes them (review 0003 Q26).
753
+ const PROJECTION_KEYS = new Set(['simtDevSha', 'version']);
754
+
755
+ /** The `projection` object a manifest carries; anything that is not an object is nothing. */
756
+ function projectionOf(value) {
757
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
758
+ }
759
+
760
+ /** Only the generated sub-keys of a `projection` object, in the order this writer emits them. */
761
+ function generatedProjection(value) {
762
+ const out = {};
763
+ for (const key of PROJECTION_KEYS) if (key in projectionOf(value)) out[key] = value[key];
764
+ return out;
765
+ }
766
+
724
767
  /**
725
768
  * A manifest with this tool's own fields replaced by what this build writes, and
726
769
  * nothing else touched.
@@ -731,13 +774,22 @@ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
731
774
  * repo chose is never compared" is a promise about the repair too, not only about
732
775
  * the report, and a repair is exactly where it would be broken.
733
776
  *
777
+ * `projection` merges the same way, one level down: the object the repository has
778
+ * keeps every sub-key it chose, and the generated sub-keys take this build's values.
779
+ *
734
780
  * @param {object} manifest the repository's manifest, as parsed
735
781
  * @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
736
782
  * @returns {object} a new manifest; the argument is not mutated
737
783
  */
738
784
  export function withGenerated(manifest, expected) {
739
785
  const patched = { ...manifest };
740
- for (const key of GENERATED_KEYS) if (key in expected) patched[key] = expected[key];
786
+ for (const key of GENERATED_KEYS) {
787
+ if (!(key in expected)) continue;
788
+ patched[key] =
789
+ key === 'projection'
790
+ ? { ...projectionOf(manifest?.projection), ...expected.projection }
791
+ : expected[key];
792
+ }
741
793
  return patched;
742
794
  }
743
795
 
@@ -780,9 +832,14 @@ export async function manifestDrift(root, stack, sha) {
780
832
  // `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
781
833
  // fields are ours, and the answer is the set above. Iterating the expected object
782
834
  // asks the same question of a different list and can only agree by coincidence.
783
- const stale = [...GENERATED_KEYS].filter(
784
- (key) => key in expected && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key]),
785
- );
835
+ const stale = [...GENERATED_KEYS].filter((key) => {
836
+ if (!(key in expected)) return false;
837
+ // `projection` compares on the sub-keys this tool writes: a sub-key the repo
838
+ // chose (`place`) is not a stale field, and reporting it is what made `sync`
839
+ // delete the one setting that was not stale (review 0003 Q26).
840
+ const actual = key === 'projection' ? generatedProjection(manifest[key]) : manifest[key];
841
+ return JSON.stringify(actual) !== JSON.stringify(expected[key]);
842
+ });
786
843
  if (stale.length > 0) {
787
844
  return {
788
845
  file: MANIFEST,
@@ -851,13 +908,17 @@ export async function readManifest(root) {
851
908
  }
852
909
  }
853
910
 
854
- export async function writeManifest(root, manifest) {
911
+ export async function writeManifest(root, manifest, journal = null) {
855
912
  // A repository still holding the old name gets it moved, not shadowed. Two manifests
856
913
  // in one repo is a manifest nobody can reason about, and the second would be the one
857
914
  // that is never read - so the window closes by moving the file, not by leaving both.
858
915
  const previous = join(root, MANIFEST_LEGACY);
859
916
  const fresh = join(root, MANIFEST);
860
917
  if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
861
- await writeFile(fresh, manifestText(manifest));
918
+ // A journaled manifest is one `undo` can restore, like every other repo file a
919
+ // command writes (R49). Callers that span repos or the repo boundary (shared,
920
+ // bench) pass nothing and keep the old behaviour exactly.
921
+ if (journal) await journaledWriteFile(root, journal, fresh, manifestText(manifest));
922
+ else await writeFile(fresh, manifestText(manifest));
862
923
  return fresh;
863
924
  }
@@ -165,6 +165,43 @@ export function parse(text, style) {
165
165
  };
166
166
  }
167
167
 
168
+ /**
169
+ * The line spans of every generated region in `text`, across both marker styles.
170
+ *
171
+ * `parse` is the single reader: a malformed region is still a region (an edit
172
+ * anywhere in the file stays denied), so malformed counts as a span covering the
173
+ * whole file rather than as absent. Line numbers are 1-based and inclusive.
174
+ *
175
+ * @returns {{malformed: boolean, spans: Array<{start: number, end: number}>}}
176
+ */
177
+ export function regionSpans(text) {
178
+ const spans = [];
179
+ let malformed = false;
180
+ for (const style of ['html', 'hash']) {
181
+ const state = parse(text, style);
182
+ if (state.status === 'malformed') {
183
+ malformed = true;
184
+ continue;
185
+ }
186
+ if (state.status !== 'ok') continue;
187
+ const start = state.before === '' ? 1 : state.before.split('\n').length + 1;
188
+ const bodyLines = state.body === '' ? 0 : state.body.split('\n').length;
189
+ spans.push({ start, end: start + bodyLines + 1 });
190
+ }
191
+ if (malformed) {
192
+ const total = text.split('\n').length;
193
+ return { malformed: true, spans: [{ start: 1, end: total }] };
194
+ }
195
+ return { malformed: false, spans };
196
+ }
197
+
198
+ /** Line range of `text.slice(index, index + length)`, 1-based and inclusive. */
199
+ export function lineRangeOf(text, index, length) {
200
+ const start = text.slice(0, index).split('\n').length;
201
+ const end = text.slice(0, index + length).split('\n').length;
202
+ return { start, end };
203
+ }
204
+
168
205
  function malformed(lines, from, at, reason) {
169
206
  return {
170
207
  status: 'malformed',
@@ -359,9 +396,12 @@ export function inject(text, body, meta, opts = {}) {
359
396
  // too. A body that matches with a seam that does not is red on `format:check`, and
360
397
  // reporting `unchanged` there means `check` finds no drift and `sync` never repairs
361
398
  // it - the repository stays red with no remedy the tool offers. Top placement is
362
- // excluded because there the splice is a repair that legitimately moves bytes
363
- // outside the markers, so the two files differ for a reason unrelated to the seam.
364
- const settled = unchanged && (where === 'top' || spliced === text);
399
+ // excluded from the seam test because there the splice is a repair that
400
+ // legitimately moves bytes outside the markers, so the two files differ for a
401
+ // reason unrelated to the seam. Position is the test there instead: a region below
402
+ // the file's own settings has been moved, and the move is what `where: 'top'`
403
+ // exists to repair (review 0003 Q26).
404
+ const settled = unchanged && (where === 'top' ? state.before.trim() === '' : spliced === text);
365
405
  if (settled) return { ...seen, text, action: 'unchanged' };
366
406
 
367
407
  return { ...seen, text: spliced, action: 'updated' };
@@ -28,6 +28,7 @@ import { dirname, join, resolve } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
 
30
30
  import { parse } from './markers.mjs';
31
+ import { MANIFEST_FILE } from '../name.mjs';
31
32
 
32
33
  // ROOT is computed here rather than imported: project.mjs is the caller of every
33
34
  // function below, so importing it back would create a cycle for the sake of one
@@ -148,9 +149,18 @@ function parseSubstitutions(text) {
148
149
  const scope = next ? tail.slice(0, next.index + 1) : tail;
149
150
  const def = /^\s*default:\s*(.*)$/m.exec(scope);
150
151
  const desc = /^\s*describe:\s*(.*)$/m.exec(scope);
152
+ // A per-signal default: `default-<signal>:` picks the value when the repository
153
+ // carries that signal (a stack tag or a detection fact). `default:` is the
154
+ // fallback. The two cannot collide: `default:` requires the colon immediately
155
+ // after the word, and a signal line has a `-` there.
156
+ const perSignal = {};
157
+ for (const pm of scope.matchAll(/^\s*default-([a-z0-9][a-z0-9-]*):\s*(.*)$/gm)) {
158
+ perSignal[pm[1]] = unquote(pm[2].trim());
159
+ }
151
160
  out.push({
152
161
  key: hit[1],
153
162
  default: def ? unquote(def[1].trim()) : '',
163
+ perSignal,
154
164
  describe: desc ? unquote(desc[1].trim()) : '',
155
165
  });
156
166
  }
@@ -197,6 +207,19 @@ function stripFrontmatter(text) {
197
207
  .trim();
198
208
  }
199
209
 
210
+ /**
211
+ * The default a signal set selects: the first per-signal default whose signal is
212
+ * present, else the plain default. Signals carry detection facts as well as stack
213
+ * tags, because a Go repository is selected on a fact, not a stack (review 0003 Q32).
214
+ */
215
+ function pickDefault(sub, signals) {
216
+ const perSignal = sub.perSignal ?? {};
217
+ for (const s of signals) {
218
+ if (Object.prototype.hasOwnProperty.call(perSignal, s)) return perSignal[s];
219
+ }
220
+ return sub.default;
221
+ }
222
+
200
223
  /**
201
224
  * Substitute declared placeholders.
202
225
  *
@@ -209,9 +232,11 @@ function stripFrontmatter(text) {
209
232
  * rendered rule would ship a literal `{{key}}`, which reads as a finished rule
210
233
  * and is not one.
211
234
  */
212
- export function renderMdc(rule, declared = {}) {
235
+ export function renderMdc(rule, declared = {}, signals = []) {
213
236
  const problems = [];
214
- let body = rule.body;
237
+ // The rule text names the manifest with a token and the render substitutes it,
238
+ // so a rename of the file is a change in one place (MANIFEST_FILE).
239
+ let body = rule.body.replaceAll('{manifest}', MANIFEST_FILE);
215
240
 
216
241
  for (const sub of rule.substitutions) {
217
242
  const has = Object.prototype.hasOwnProperty.call(declared, sub.key);
@@ -226,17 +251,19 @@ export function renderMdc(rule, declared = {}) {
226
251
  }
227
252
 
228
253
  if (provided === undefined || provided === null || provided === '') {
229
- // Fall back to the default. An empty DEFAULT is also a drop instruction,
230
- // which is how the same file serves a repo that has nothing to add.
231
- if (sub.default === '') {
254
+ // Fall back to the default the signals select. An empty chosen default is
255
+ // also a drop instruction, which is how the same file serves a repo that
256
+ // has nothing to add.
257
+ const chosen = pickDefault(sub, signals);
258
+ if (chosen === '') {
232
259
  body = dropOrReplace(body, sub.key, '');
233
260
  continue;
234
261
  }
235
- if (sub.default === undefined) {
262
+ if (chosen === undefined) {
236
263
  problems.push(`substitution ${sub.key} has no default and none declared`);
237
264
  continue;
238
265
  }
239
- body = dropOrReplace(body, sub.key, sub.default);
266
+ body = dropOrReplace(body, sub.key, chosen);
240
267
  continue;
241
268
  }
242
269
 
@@ -16,7 +16,8 @@
16
16
  // same matcher, so supporting an ecosystem nobody here has heard of requires no
17
17
  // code change and no release.
18
18
 
19
- import { readFile, readdir } from 'node:fs/promises';
19
+ import { constants as fsConstants } from 'node:fs';
20
+ import { access, readFile, readdir, stat } from 'node:fs/promises';
20
21
  import { join } from 'node:path';
21
22
 
22
23
  import { baselineTargets, misplaced } from './baselines.mjs';
@@ -31,8 +32,75 @@ import { measureRoot } from '../measure/context.mjs';
31
32
  import { loadMdcRules, renderMdc } from './mdc.mjs';
32
33
  import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
33
34
  import { pointerState, pointerTargets, ruleTargets, scopedTargets } from './targets.mjs';
34
- import { CLI } from '../name.mjs';
35
- import { MANIFEST_FILE } from '../name.mjs';
35
+ import { CLI, LOCK_FILE, LOCK_LEGACY, PACKAGE_NAME } from '../name.mjs';
36
+ import { MANIFEST_FILE, WORKFLOW_FILE_NAME } from '../name.mjs';
37
+ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
38
+ import { LOCAL_BIN } from '../distribute/release.mjs';
39
+
40
+ /**
41
+ * A checked-in gate workflow the locator cannot resolve (review 0003 Q31).
42
+ *
43
+ * Returns a finding, or null when some resolution path can still succeed. Only
44
+ * committed evidence counts: the pinned devDependency, the release binary, and
45
+ * `simt` on this machine's PATH (a global install CI may share — its presence
46
+ * here is a reason to stay silent, its absence is not proof CI lacks it, so the
47
+ * finding also requires the first two to be missing).
48
+ */
49
+ async function workflowResolvable(root) {
50
+ let pkg = null;
51
+ try {
52
+ pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
53
+ } catch {
54
+ pkg = null;
55
+ }
56
+ if (pkg !== null) {
57
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
58
+ if (Object.hasOwn(deps, PACKAGE_NAME)) return null;
59
+ }
60
+ try {
61
+ await stat(join(root, LOCAL_BIN));
62
+ return null;
63
+ } catch {
64
+ // No release binary: keep checking the last path.
65
+ }
66
+ for (const dir of (process.env.PATH ?? '').split(':').filter(Boolean)) {
67
+ try {
68
+ await access(join(dir, 'simt'), fsConstants.X_OK);
69
+ return null;
70
+ } catch {
71
+ // Not here; keep looking.
72
+ }
73
+ }
74
+ const file = `.github/workflows/${WORKFLOW_FILE_NAME}`;
75
+ if (pkg !== null) {
76
+ const lockfiles = [];
77
+ for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
78
+ try {
79
+ await stat(join(root, name));
80
+ lockfiles.push(name);
81
+ } catch {
82
+ // Absent is the normal case.
83
+ }
84
+ }
85
+ const runner = resolveNodeRunner({
86
+ lockfiles,
87
+ packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
88
+ });
89
+ return {
90
+ file,
91
+ status: 'unresolvable',
92
+ detail: `the checked-in gate workflow looks for ${PACKAGE_NAME} in node_modules, but nothing it tries can succeed: no pinned devDependency, no ${LOCAL_BIN}, no simt on PATH`,
93
+ remedy: `run: ${runner.addDev} ${PACKAGE_NAME} (the gate runs from the pinned dependency)`,
94
+ };
95
+ }
96
+ return {
97
+ file,
98
+ status: 'unresolvable',
99
+ detail: `the checked-in gate workflow cannot resolve ${PACKAGE_NAME}: no package.json to pin it in, no ${LOCAL_BIN}, no simt on PATH`,
100
+ remedy: `install the pinned gate binary into the repository, then re-run: simt init --yes`,
101
+ };
102
+ }
103
+ import { computeLock } from './lock.mjs';
36
104
  import {
37
105
  desiredRegion,
38
106
  provenanceDigest,
@@ -53,6 +121,16 @@ import {
53
121
  * most agents, including ones that ignore .cursor/ entirely. */
54
122
  export const PRIMARY_TARGET = 'AGENTS.md';
55
123
 
124
+ /** The gate's verdict on a region `sync` has already written. `current` rows carry
125
+ * `detail: null`, not `undefined`: a key whose value is `undefined` survives into the
126
+ * structured MCP surface but is dropped by `JSON.stringify`, so the two surfaces of one
127
+ * answer no longer deep-equal. */
128
+ function regionVerdict(action) {
129
+ return action === 'unchanged'
130
+ ? { status: 'current', detail: null }
131
+ : { status: 'drift', detail: `region is ${action}; run: ${CLI} sync --yes` };
132
+ }
133
+
56
134
 
57
135
  /**
58
136
  * @param {string} root repo root
@@ -62,7 +140,7 @@ export const PRIMARY_TARGET = 'AGENTS.md';
62
140
  export async function plan(root, flags = {}) {
63
141
  const desired = await desiredRegion(root, flags);
64
142
  const { stack, facts, profiles, fallback, notes, body, meta, all, manifest } = desired;
65
- const target = await targetFor(root, body, meta);
143
+ const target = await targetFor(root, root, body, meta);
66
144
 
67
145
  // Per-rule content projects to every editor the repo selected, not only Cursor. The
68
146
  // body is identical on each; only the frontmatter the editor reads before any rule
@@ -94,9 +172,39 @@ export async function plan(root, flags = {}) {
94
172
  // root's would read as though the packages were the repository.
95
173
  const packages = await packageRegions(root, flags, profiles);
96
174
  const nested = await Promise.all(
97
- packages.regions.map((r) => targetFor(join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
175
+ packages.regions.map((r) => targetFor(root, join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
98
176
  );
99
177
 
178
+ // The lock records the two digests a frozen check compares (ADR-086). It is a target
179
+ // like every other: `plan` produces it, so preview, `--diff`, the journal, the idle
180
+ // test and `init` all inherit it. Computing it here rather than in `sync` is what makes
181
+ // a deleted lock show up in the plan as a write instead of vanishing from it — the
182
+ // `sync` that printed "wrote simt-dev.lock.json" and then "nothing written" (Q29).
183
+ const lock = await computeLock(root, flags);
184
+ const lockText = `${JSON.stringify(lock, null, 2)}\n`;
185
+ let previousLockText = '';
186
+ let lockExists = false;
187
+ for (const name of [LOCK_FILE, LOCK_LEGACY]) {
188
+ try {
189
+ previousLockText = await readFile(join(root, name), 'utf8');
190
+ lockExists = true;
191
+ break;
192
+ } catch (error) {
193
+ if (error.code !== 'ENOENT') throw error;
194
+ }
195
+ }
196
+ const lockTarget = {
197
+ file: LOCK_FILE,
198
+ path: join(root, LOCK_FILE),
199
+ kind: 'lock',
200
+ surface: null,
201
+ exists: lockExists,
202
+ action: !lockExists ? 'create' : previousLockText === lockText ? 'unchanged' : 'update',
203
+ bytes: Buffer.byteLength(lockText),
204
+ previousBytes: Buffer.byteLength(previousLockText),
205
+ text: lockText,
206
+ };
207
+
100
208
  return {
101
209
  root,
102
210
  stack,
@@ -107,15 +215,25 @@ export async function plan(root, flags = {}) {
107
215
  surfaceSource: surfaces.source,
108
216
  surfaceProblems: surfaces.problems,
109
217
  rules: all,
110
- targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets],
218
+ targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets, lockTarget],
111
219
  packages: packages.regions.map((r) => ({ path: r.path, profiles: r.profiles })),
112
220
  mdcProblems: mdc.problems,
221
+ crossRefs: desired.crossRefs,
113
222
  notes: [...notes, ...mdc.notes, ...packages.notes],
114
223
  };
115
224
  }
116
225
 
117
- async function targetFor(root, body, meta, label = PRIMARY_TARGET) {
118
- const path = join(root, PRIMARY_TARGET);
226
+ /**
227
+ * Build the write target for one AGENTS.md. `root` is the repository: `regionOpts`
228
+ * reads its manifest, because placement is a repository-level decision that holds for
229
+ * every package's region too. `dir` is where the file lives, which differs from
230
+ * `root` only for a nested monorepo package (review 0003 Q26).
231
+ *
232
+ * @param {string} root repo root
233
+ * @param {string} dir directory the target file lives in
234
+ */
235
+ async function targetFor(root, dir, body, meta, label = PRIMARY_TARGET) {
236
+ const path = join(dir, PRIMARY_TARGET);
119
237
  let existing = '';
120
238
  let exists = true;
121
239
  try {
@@ -186,8 +304,24 @@ export async function checkTargets(root, flags = {}) {
186
304
  // Two `.github` files, one ownership rule, so one loop over both. The gate and the
187
305
  // dependency-updates config fail for the same reason and are fixed the same way,
188
306
  // and listing them separately is how the second copy of this rule gets written.
307
+ const workflowPlan = await planWorkflow(root);
308
+ // Q31: a workflow that exists but cannot resolve simt-dev by any path the
309
+ // locator tries is a green tick on a gate that never runs. The locator tries
310
+ // `$SIMT_DEV_BIN`, the pinned devDependency, the release binary and `simt`
311
+ // on PATH, in that order; only the middle two are committed evidence this
312
+ // check can see. When the repo pins nothing and carries no binary, the check
313
+ // can still see `simt` on its own PATH — a global install CI may share — so
314
+ // it stays silent then rather than crying wolf about a resolution it cannot
315
+ // disprove. The remedy names the repo's own manager, never a bare npm guess.
316
+ // simt-dev's own copy is exempt: it runs this checkout directly and never asks the
317
+ // locator to find a binary, so a machine without `simt` on PATH is not evidence that
318
+ // the gate cannot start. The consumer copy is not exempt, because its locator does.
319
+ if (!workflowPlan.own && workflowPlan.action !== 'create' && workflowPlan.action !== 'skip') {
320
+ const unresolvable = await workflowResolvable(root);
321
+ if (unresolvable) findings.push(unresolvable);
322
+ }
189
323
  for (const [plan, what] of [
190
- [await planWorkflow(root), 'nothing gates it'],
324
+ [workflowPlan, 'nothing gates it'],
191
325
  [await planDependabot(root), 'nothing watches its dependencies'],
192
326
  ]) {
193
327
  if (plan.action === 'create') {
@@ -258,7 +392,20 @@ export async function checkTargets(root, flags = {}) {
258
392
  for (const p of resolved.problems) {
259
393
  findings.push({ file: `${MANIFEST_FILE}`, status: 'invalid', detail: p, remedy: `known surfaces: ${SURFACE_IDS.join(', ')}` });
260
394
  }
261
- const rules = all.filter((r) => isEligible(r, new Set([...stack, ...facts]), declared.optIn));
395
+ const signals = new Set([...stack, ...facts]);
396
+ const rules = all.filter((r) => isEligible(r, signals, declared.optIn));
397
+
398
+ // A dangling cross-reference is a rule-text defect: the region renders, but it points
399
+ // the reader at a profile they cannot reach. A gate failure, like any other rule defect.
400
+ for (const p of desired.crossRefs ?? []) {
401
+ const idx = p.indexOf(': ');
402
+ findings.push({
403
+ file: p.slice(0, idx),
404
+ status: 'invalid',
405
+ detail: p.slice(idx + 2),
406
+ remedy: `co-select the profile, or name it with ${CLI} rules show <id>`,
407
+ });
408
+ }
262
409
 
263
410
  const sha = await provenanceDigest();
264
411
  const notes = [];
@@ -286,8 +433,7 @@ export async function checkTargets(root, flags = {}) {
286
433
  findings.push({
287
434
  file: target.file,
288
435
  surface: target.surface,
289
- status: action === 'unchanged' ? 'current' : 'drift',
290
- detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
436
+ ...regionVerdict(action),
291
437
  recordedSha: parse(target.previous).sha ?? null,
292
438
  });
293
439
  }
@@ -308,6 +454,7 @@ export async function checkTargets(root, flags = {}) {
308
454
  const { body, problems } = renderMdc(
309
455
  rule,
310
456
  ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
457
+ signals,
311
458
  );
312
459
  if (problems.length > 0) {
313
460
  findings.push({
@@ -359,8 +506,7 @@ export async function checkTargets(root, flags = {}) {
359
506
  findings.push({
360
507
  file: rel,
361
508
  surface: target.surface,
362
- status: action === 'unchanged' ? 'current' : 'drift',
363
- detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
509
+ ...regionVerdict(action),
364
510
  recordedSha: state.sha ?? null,
365
511
  });
366
512
  }
@@ -395,8 +541,7 @@ export async function checkTargets(root, flags = {}) {
395
541
  }
396
542
  findings.push({
397
543
  file: t.file,
398
- status: t.action === 'unchanged' ? 'current' : 'drift',
399
- detail: t.action === 'unchanged' ? undefined : `region is ${t.action}; run: ${CLI} sync --yes`,
544
+ ...regionVerdict(t.action),
400
545
  recordedSha: state.sha ?? null,
401
546
  });
402
547
  const off = misplaced(state, t.style);
@@ -566,8 +711,7 @@ async function checkRegion(root, dir, body, meta) {
566
711
  const d = diff(existing, body, meta, await regionOpts(root));
567
712
  return {
568
713
  file: PRIMARY_TARGET,
569
- status: d.action === 'unchanged' ? 'current' : 'drift',
570
- detail: d.action === 'unchanged' ? undefined : `region is ${d.action}; run: ${CLI} sync --yes`,
714
+ ...regionVerdict(d.action),
571
715
  recordedSha: state.sha ?? null,
572
716
  expectedSha: meta.sha,
573
717
  };