simt-dev 0.6.3 → 0.7.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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -24,36 +24,25 @@ import { journaledWriteFile } from '../journal.mjs';
24
24
  // answers to "can this repo run that gate", which is precisely the kind of drift this
25
25
  // file exists to prevent elsewhere.
26
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';
27
30
 
28
31
  // Imported as well as re-exported: a bare `export { X as Y } from` binds nothing here,
29
32
  // and the manifest name is used a dozen times below in remedy strings that name this
30
33
  // file by name.
31
34
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
35
+ import { GENERATED_KEYS_OF, KEYS, loadManifest, manifestPath } from '../manifest/schema.mjs';
32
36
  import { CLI } from '../name.mjs';
33
37
  import { packageVersion } from '../package-version.mjs';
34
38
  export const MANIFEST = MANIFEST_FILE;
35
39
 
40
+ // Still exported from here because this is where the manifest lived: every caller that
41
+ // resolved its path through this module keeps working, and the implementation moved.
42
+ export { manifestPath };
43
+
36
44
  export { MANIFEST_LEGACY };
37
45
 
38
- /**
39
- * The manifest a repository has, which is not necessarily the one this tool would write.
40
- *
41
- * The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
42
- * `join(root, MANIFEST)` and would have reported an adopted repository as unadopted the
43
- * day the constant moved - and "no manifest" means "never adopted", which is the answer
44
- * that offers to re-adopt a repo that is already done. Fourteen repositories carry the
45
- * old name on disk.
46
- *
47
- * So the old name is read as a fallback and the new one is written. `writeManifest`
48
- * renames rather than creating a second file, which is how the window closes: the first
49
- * `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
50
- */
51
- export async function manifestPath(root) {
52
- const next = join(root, MANIFEST);
53
- if (existsSync(next)) return next;
54
- const previous = join(root, MANIFEST_LEGACY);
55
- return existsSync(previous) ? previous : next;
56
- }
57
46
 
58
47
 
59
48
  /**
@@ -201,7 +190,7 @@ export const ECOSYSTEMS = [
201
190
  * fail closed — the defect is declaring a gate this repo has no way to run.
202
191
  *
203
192
  * The check mirrors what the dispatcher actually attempts:
204
- * - `script` gates run `npm run <script>`, so the script must exist
193
+ * - `script` gates run `<manager> run <script>`, so the script must exist
205
194
  * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
206
195
  * installed, not merely declared as a dependency
207
196
  * - `command` gates run whatever the manifest's `gates.commands` declares, so a
@@ -237,13 +226,16 @@ export function gateRunnable(gate, capability = {}) {
237
226
  }
238
227
  if (gate.kind === 'script') {
239
228
  const script = gate.script ?? gate.name;
240
- if (capability.scripts?.[script]) return { runnable: true, why: `runs: npm run ${script}` };
229
+ // Q31: the runnable sentence names the repo's own runner, never a bare npm
230
+ // assumption — the card, the dispatcher and this check read one resolver.
231
+ const spell = runScript(capability.runner ?? 'npm', script);
232
+ if (capability.scripts?.[script]) return { runnable: true, why: `runs: ${spell}` };
241
233
  // Saying "no script in package.json" about a repo with no package.json states a
242
234
  // fact about a file that does not exist and implies a fix that cannot be taken.
243
235
  return {
244
236
  runnable: false,
245
237
  why: foreign
246
- ? `this repo runs ${runnerName(capability.runner)}, not npm, so "npm run ${script}" cannot run here`
238
+ ? `this repo runs ${runnerName(capability.runner)}, not npm, so "${spell}" cannot run here`
247
239
  : `no "${script}" script in package.json`,
248
240
  };
249
241
  }
@@ -277,11 +269,17 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
277
269
  const name = gate?.name ?? 'the gate';
278
270
  const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
279
271
  const foreign = isForeignRunner(runner);
272
+ // Q31: a missing tool in a Node repo names the repo's own installer —
273
+ // `pnpm add -D`, not `npm i -D` — and anything elsewhere keeps the honest
274
+ // undo: name the program under `gates.commands`.
275
+ const addDev = { npm: 'npm i -D', pnpm: 'pnpm add -D', yarn: 'yarn add -D', bun: 'bun add -d' }[runner] ?? null;
280
276
  const fix =
281
277
  npmShaped && foreign
282
278
  ? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
283
279
  : gate?.kind === 'staged'
284
- ? `install ${gate.tools.join(', ')} as a dev dependency`
280
+ ? addDev
281
+ ? `install ${gate.tools.join(', ')} as a dev dependency: ${addDev} ${gate.tools.join(' ')} (the gate runs from the pinned dependency)`
282
+ : `install ${gate.tools.join(', ')} as a dev dependency`
285
283
  : gate?.kind === 'script'
286
284
  ? `add a "${gate.script ?? name}" script to package.json`
287
285
  : gate?.kind === 'command'
@@ -302,11 +300,14 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
302
300
  /**
303
301
  * Is this runner one where `npm run` is not how a gate is spelled?
304
302
  *
305
- * `undefined` means "the caller did not report a runner", and `npm` means the npm
306
- * remedy applies. Only a positively identified other ecosystem changes the wording.
303
+ * `undefined` means "the caller did not report a runner", and every Node manager
304
+ * (`npm`, `pnpm`, `yarn`, `bun`) spells gates as `<manager> run <script>` with
305
+ * scripts living in the same `package.json`. Only a positively identified
306
+ * non-Node runner changes the wording. (Q31: pnpm is Node-shaped, not foreign.)
307
307
  */
308
308
  function isForeignRunner(runner) {
309
- return runner != null && runner !== 'npm';
309
+ if (runner == null) return false;
310
+ return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
310
311
  }
311
312
 
312
313
  /** How a reader would name the runner in a sentence. */
@@ -316,6 +317,9 @@ function runnerName(runner) {
316
317
  none: 'no recognised runner',
317
318
  gradle: 'Gradle',
318
319
  python: 'Python',
320
+ npm: 'npm',
321
+ pnpm: 'pnpm',
322
+ yarn: 'yarn',
319
323
  bun: 'bun',
320
324
  cargo: 'Cargo',
321
325
  go: 'Go',
@@ -383,14 +387,15 @@ export function planGates(stack = [], capability = null, facts = []) {
383
387
  */
384
388
  function ecosystemCommands(facts = [], capability = null) {
385
389
  if (!capability?.tools?.length) return {};
386
- // An npm runner already answers "how do I test and format" with its own package.json
387
- // scripts, and a seed only wins over those scripts when the repo has not declared a
388
- // command of its own — which is exactly the mixed repos (package.json AND Cargo.toml)
389
- // where the script is the deliberate answer. Seeding there would replace a choice with
390
- // a default; the reader's escape hatch is declaring gates.commands by hand, which a
391
- // seed would lose to anyway. Pure ecosystem repos have runner 'cargo'/'go'/... and
392
- // seed normally.
393
- if (capability.runner === 'npm') return {};
390
+ // A Node runner (npm/pnpm/yarn/bun) already answers "how do I test and format"
391
+ // with its own package.json scripts, and a seed only wins over those scripts when
392
+ // the repo has not declared a command of its own — which is exactly the mixed
393
+ // repos (package.json AND Cargo.toml) where the script is the deliberate answer.
394
+ // Seeding there would replace a choice with a default; the reader's escape hatch
395
+ // is declaring gates.commands by hand, which a seed would lose to anyway. Pure
396
+ // ecosystem repos have runner 'cargo'/'go'/... and seed normally.
397
+ // Q31: every Node manager skips seeding — pnpm is not "foreign", it is Node.
398
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(capability.runner)) return {};
394
399
  const present = new Set(facts);
395
400
  const commands = {};
396
401
  for (const eco of ECOSYSTEMS) {
@@ -414,7 +419,7 @@ function ecosystemCommands(facts = [], capability = null) {
414
419
  */
415
420
  export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
416
421
  const gates = defaultGates(stack, facts);
417
- const limits = defaultLimits(stack);
422
+ const limits = defaultLimits();
418
423
 
419
424
  // A re-init must never silently weaken a repo's own declaration: existing
420
425
  // limits and gates win over defaults, and only the projection stamp is refreshed.
@@ -726,7 +731,27 @@ export function declaredShared(manifest) {
726
731
  return { level, rules };
727
732
  }
728
733
 
729
- export const GENERATED_KEYS = new Set(['$comment', 'projection']);
734
+ // Derived from the table rather than restated: a key this tool writes is a `tool` key
735
+ // marked generated, and a second list here is the drift this module exists to end.
736
+ export const GENERATED_KEYS = GENERATED_KEYS_OF;
737
+
738
+
739
+ // The sub-keys of `projection` this tool writes. `projection` is a generated key, but
740
+ // not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
741
+ // one that shipped), and a repair that replaces the object deletes them (review 0003 Q26).
742
+ const PROJECTION_KEYS = new Set(['simtDevSha', 'version']);
743
+
744
+ /** The `projection` object a manifest carries; anything that is not an object is nothing. */
745
+ function projectionOf(value) {
746
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
747
+ }
748
+
749
+ /** Only the generated sub-keys of a `projection` object, in the order this writer emits them. */
750
+ function generatedProjection(value) {
751
+ const out = {};
752
+ for (const key of PROJECTION_KEYS) if (key in projectionOf(value)) out[key] = value[key];
753
+ return out;
754
+ }
730
755
 
731
756
  /**
732
757
  * A manifest with this tool's own fields replaced by what this build writes, and
@@ -738,13 +763,22 @@ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
738
763
  * repo chose is never compared" is a promise about the repair too, not only about
739
764
  * the report, and a repair is exactly where it would be broken.
740
765
  *
766
+ * `projection` merges the same way, one level down: the object the repository has
767
+ * keeps every sub-key it chose, and the generated sub-keys take this build's values.
768
+ *
741
769
  * @param {object} manifest the repository's manifest, as parsed
742
770
  * @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
743
771
  * @returns {object} a new manifest; the argument is not mutated
744
772
  */
745
773
  export function withGenerated(manifest, expected) {
746
774
  const patched = { ...manifest };
747
- for (const key of GENERATED_KEYS) if (key in expected) patched[key] = expected[key];
775
+ for (const key of GENERATED_KEYS) {
776
+ if (!(key in expected)) continue;
777
+ patched[key] =
778
+ key === 'projection'
779
+ ? { ...projectionOf(manifest?.projection), ...expected.projection }
780
+ : expected[key];
781
+ }
748
782
  return patched;
749
783
  }
750
784
 
@@ -787,9 +821,14 @@ export async function manifestDrift(root, stack, sha) {
787
821
  // `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
788
822
  // fields are ours, and the answer is the set above. Iterating the expected object
789
823
  // asks the same question of a different list and can only agree by coincidence.
790
- const stale = [...GENERATED_KEYS].filter(
791
- (key) => key in expected && JSON.stringify(manifest[key]) !== JSON.stringify(expected[key]),
792
- );
824
+ const stale = [...GENERATED_KEYS].filter((key) => {
825
+ if (!(key in expected)) return false;
826
+ // `projection` compares on the sub-keys this tool writes: a sub-key the repo
827
+ // chose (`place`) is not a stale field, and reporting it is what made `sync`
828
+ // delete the one setting that was not stale (review 0003 Q26).
829
+ const actual = key === 'projection' ? generatedProjection(manifest[key]) : manifest[key];
830
+ return JSON.stringify(actual) !== JSON.stringify(expected[key]);
831
+ });
793
832
  if (stale.length > 0) {
794
833
  return {
795
834
  file: MANIFEST,
@@ -842,20 +881,17 @@ export function manifestText(manifest) {
842
881
  return `${JSON.stringify(manifest, null, 2)}\n`;
843
882
  }
844
883
 
845
- /** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
884
+ /**
885
+ * The manifest in the shape the callers already read, now answered by the one loader
886
+ * (review 0003 Q69). `error` is the unreadable-file case only: a manifest with a value
887
+ * the schema cannot use is still a manifest, and refusing it here would turn every
888
+ * reader into a second validator.
889
+ *
890
+ * @returns {Promise<{manifest: object|null, exists: boolean, error: string|null}>}
891
+ */
846
892
  export async function readManifest(root) {
847
- const path = await manifestPath(root);
848
- let text;
849
- try {
850
- text = await readFile(path, 'utf8');
851
- } catch {
852
- return { manifest: null, exists: false, error: null };
853
- }
854
- try {
855
- return { manifest: JSON.parse(text), exists: true, error: null };
856
- } catch (error) {
857
- return { manifest: null, exists: true, error: `unparseable: ${error.message}` };
858
- }
893
+ const { manifest, exists, problems } = await loadManifest(root);
894
+ return { manifest, exists, error: exists && manifest === null ? (problems[0] ?? 'unparseable') : null };
859
895
  }
860
896
 
861
897
  export async function writeManifest(root, manifest, journal = null) {
@@ -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