simt-dev 0.6.3 → 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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. 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
  10. 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
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. 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
  15. 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
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -146,10 +146,16 @@ export const LOADERS = [
146
146
  nested: 'cumulative up to the project root at session start; files below the workspace load when that area is read',
147
147
  skills: ['.agents/skills', '.opencode/skills', '.claude/skills'],
148
148
  hooks: {
149
- path: '.opencode/plugins/',
149
+ path: '.opencode/plugins/simt-dev.js',
150
150
  verified:
151
151
  'OpenCode v2 plugins docs: direct .ts and .js files and immediate plugin packages are loaded from every discovered .opencode/plugins/ directory, and the plugins array in opencode.json(c)',
152
152
  },
153
+ agents: {
154
+ dir: '.opencode/agents',
155
+ ext: '.md',
156
+ verified:
157
+ 'OpenCode v2 agents docs (opencode.ai/v2/docs/agents, read 2026-10-30): subagents are markdown files with a description frontmatter under .opencode/agents/',
158
+ },
153
159
  mcp: {
154
160
  path: 'opencode.json',
155
161
  key: 'mcp.servers',
@@ -242,6 +248,12 @@ export const LOADERS = [
242
248
  verified:
243
249
  'Claude Code hooks docs, hook locations: project settings at .claude/settings.json and .claude/settings.local.json, user settings at ~/.claude/settings.json',
244
250
  },
251
+ agents: {
252
+ dir: '.claude/agents',
253
+ ext: '.md',
254
+ verified:
255
+ 'Claude Code subagents docs (code.claude.com/docs/en/sub-agents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .claude/agents/',
256
+ },
245
257
  mcp: {
246
258
  path: '.mcp.json',
247
259
  key: null,
@@ -320,6 +332,12 @@ export const LOADERS = [
320
332
  verified:
321
333
  'Codex hooks docs: <repo>/.codex/hooks.json or ~/.codex/hooks.json, or inline [hooks] tables in config.toml',
322
334
  },
335
+ agents: {
336
+ dir: '.codex/agents',
337
+ ext: '.toml',
338
+ verified:
339
+ 'Codex custom agents docs (developers.openai.com/codex/subagents, read 2026-10-30): standalone TOML files under .codex/agents/ defining name, description and developer_instructions',
340
+ },
323
341
  mcp: {
324
342
  path: '.codex/config.toml',
325
343
  key: 'mcp_servers',
@@ -386,6 +404,12 @@ export const LOADERS = [
386
404
  path: '.cursor/hooks.json',
387
405
  verified: 'Cursor agent hooks docs: <project-root>/.cursor/hooks.json and ~/.cursor/hooks.json',
388
406
  },
407
+ agents: {
408
+ dir: '.cursor/agents',
409
+ ext: '.md',
410
+ verified:
411
+ 'Cursor subagents docs (cursor.com/docs/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .cursor/agents/',
412
+ },
389
413
  mcp: {
390
414
  path: '.cursor/mcp.json',
391
415
  key: null,
@@ -470,6 +494,12 @@ export const LOADERS = [
470
494
  path: '.agents/hooks.json',
471
495
  verified: 'Antigravity hooks docs: workspace level .agents/hooks.json, global ~/.gemini/config/hooks.json',
472
496
  },
497
+ agents: {
498
+ dir: '.agents/agents',
499
+ ext: '.md',
500
+ verified:
501
+ 'Google Antigravity subagents docs (antigravity.google/docs/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .agents/agents/',
502
+ },
473
503
  mcp: {
474
504
  path: '.agents/mcp_config.json',
475
505
  key: null,
@@ -541,6 +571,12 @@ export const LOADERS = [
541
571
  verified:
542
572
  'Gemini CLI hooks reference (geminicli.com/docs/hooks/reference/, read 2026-10-26): a project .gemini/settings.json carries a top-level hooks object whose events are arrays of {matcher, hooks:[{type:"command",command,timeout? (milliseconds)}]} — the same shape Claude and Codex use; BeforeTool denies with {decision:"deny",reason}, BeforeAgent and AfterTool inject via hookSpecificOutput.additionalContext',
543
573
  },
574
+ agents: {
575
+ dir: '.gemini/agents',
576
+ ext: '.md',
577
+ verified:
578
+ 'Gemini CLI subagents docs (geminicli.com/docs/core/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .gemini/agents/',
579
+ },
544
580
  mcp: {
545
581
  path: '.gemini/settings.json',
546
582
  key: 'mcpServers',
@@ -603,6 +639,8 @@ export const LOADERS = [
603
639
  skillsNote: 'no project skill directory is documented for Copilot',
604
640
  hooks: null,
605
641
  hooksNote: 'no hooks file is documented for Copilot in any first-party page checked on 2026-10-14',
642
+ agents: null,
643
+ agentsNote: 'no file-based agents are documented for Copilot in any first-party page checked on 2026-10-30',
606
644
  mcp: {
607
645
  path: '.vscode/mcp.json',
608
646
  key: null,
@@ -707,6 +745,8 @@ export const LOADERS = [
707
745
  hooks: null,
708
746
  hooksNote:
709
747
  'DeepSeek harness hooks docs: the Claude Code and Codex bridges take an explicit configPath, and project-local hooks.json discovery is still marked TODO, so no repository path is loaded by default',
748
+ agents: null,
749
+ agentsNote: 'no file-based agents are documented for the DeepSeek harness in any first-party page checked on 2026-10-30',
710
750
  mcp: {
711
751
  path: null,
712
752
  key: '@deepseek-ai/dsh-mcp-client',
@@ -13,11 +13,10 @@ import { join } from 'node:path';
13
13
 
14
14
  import { readLockedVariants } from '../kernel/variants.mjs';
15
15
  import { fingerprint } from '../resolve/fingerprint.mjs';
16
+ import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
16
17
  import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
17
18
  import { readManifest } from './manifest.mjs';
18
19
 
19
- export const LOCK_FILE = 'simt-dev.lock.json';
20
-
21
20
  /** Manifest keys that change what the region contains. Anything else in the file is
22
21
  * the repository's own notes, and hashing it would make a comment fail the lock. */
23
22
  const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
@@ -28,12 +27,15 @@ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
28
27
  * @param {string} root
29
28
  * @param {object} [flags] the same flags `sync` rendered with, so the output is the
30
29
  * body that was written and not a body for a different set of surfaces
30
+ * @param {object} [manifest] the manifest to digest, in place of the one on disk. A
31
+ * writer that is about to write a manifest (a fresh `init`) passes it here so the
32
+ * lock records the state the repo will have, not the state it has (Q29).
31
33
  */
32
- export async function computeLock(root, flags = {}) {
34
+ export async function computeLock(root, flags = {}, manifest = undefined) {
33
35
  const variants =
34
36
  flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
35
37
  const desired = await desiredRegion(root, { ...flags, variants });
36
- const inputs = await inputDigest(root, variants);
38
+ const inputs = await inputDigest(root, variants, manifest);
37
39
  const outputs = createHash('sha256').update(desired.body).digest('hex');
38
40
  const hasVariants =
39
41
  variants && typeof variants === 'object' && Object.keys(variants).length > 0;
@@ -41,7 +43,7 @@ export async function computeLock(root, flags = {}) {
41
43
  }
42
44
 
43
45
  /**
44
- * Record locked variant winners in `simt-dev.lock.json` alongside updated digests.
46
+ * Record locked variant winners in `simt.lock.json` alongside updated digests.
45
47
  */
46
48
  export async function writeLockVariants(root, nextVariants, flags = {}) {
47
49
  const existing = await readLockedVariants(root);
@@ -53,15 +55,20 @@ export async function writeLockVariants(root, nextVariants, flags = {}) {
53
55
 
54
56
  /** Read a lock, or null when the file is not there. A file that does not parse is an
55
57
  * error the caller reports, not a missing lock: absence and damage are different
56
- * repairs. */
58
+ * repairs. A lock under the legacy name is a lock (ADR-110): it is read until the
59
+ * next write migrates it, and a permission failure on the new name is not an excuse
60
+ * to fall back to an older file. */
57
61
  export async function readLock(root) {
58
- let text;
59
- try {
60
- text = await readFile(join(root, LOCK_FILE), 'utf8');
61
- } catch (error) {
62
- if (error.code === 'ENOENT') return { lock: null, error: null };
63
- throw error;
62
+ let text = null;
63
+ for (const name of [LOCK_FILE, LOCK_LEGACY]) {
64
+ try {
65
+ text = await readFile(join(root, name), 'utf8');
66
+ break;
67
+ } catch (error) {
68
+ if (error.code !== 'ENOENT') throw error;
69
+ }
64
70
  }
71
+ if (text === null) return { lock: null, error: null };
65
72
  try {
66
73
  const lock = JSON.parse(text);
67
74
  if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
@@ -79,13 +86,13 @@ export async function readLock(root) {
79
86
  }
80
87
  }
81
88
 
82
- async function inputDigest(root, variants = {}) {
89
+ async function inputDigest(root, variants = {}, manifest = undefined) {
83
90
  const hash = createHash('sha256');
84
91
  hash.update(`provenance:${await provenanceDigest()}\n`);
85
92
  const { stack, facts } = await fingerprint(root);
86
93
  hash.update(`signals:${JSON.stringify({ stack, facts })}\n`);
87
- const { manifest } = await readManifest(root);
88
94
  const owned = {};
95
+ if (manifest === undefined) manifest = (await readManifest(root)).manifest;
89
96
  if (manifest && typeof manifest === 'object') {
90
97
  for (const key of MANIFEST_KEYS) {
91
98
  if (manifest[key] !== undefined) owned[key] = manifest[key];
@@ -24,6 +24,9 @@ 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
@@ -201,7 +204,7 @@ export const ECOSYSTEMS = [
201
204
  * fail closed — the defect is declaring a gate this repo has no way to run.
202
205
  *
203
206
  * The check mirrors what the dispatcher actually attempts:
204
- * - `script` gates run `npm run <script>`, so the script must exist
207
+ * - `script` gates run `<manager> run <script>`, so the script must exist
205
208
  * - `staged` gates look for each named tool in `node_modules/.bin`, so it must be
206
209
  * installed, not merely declared as a dependency
207
210
  * - `command` gates run whatever the manifest's `gates.commands` declares, so a
@@ -237,13 +240,16 @@ export function gateRunnable(gate, capability = {}) {
237
240
  }
238
241
  if (gate.kind === 'script') {
239
242
  const script = gate.script ?? gate.name;
240
- 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}` };
241
247
  // Saying "no script in package.json" about a repo with no package.json states a
242
248
  // fact about a file that does not exist and implies a fix that cannot be taken.
243
249
  return {
244
250
  runnable: false,
245
251
  why: foreign
246
- ? `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`
247
253
  : `no "${script}" script in package.json`,
248
254
  };
249
255
  }
@@ -277,11 +283,17 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
277
283
  const name = gate?.name ?? 'the gate';
278
284
  const npmShaped = gate?.kind === 'staged' || gate?.kind === 'script';
279
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;
280
290
  const fix =
281
291
  npmShaped && foreign
282
292
  ? `this repo runs ${runnerName(runner)}, not npm; name the program under "gates.commands" in ${MANIFEST_FILE} instead of the npm equivalent`
283
293
  : gate?.kind === 'staged'
284
- ? `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`
285
297
  : gate?.kind === 'script'
286
298
  ? `add a "${gate.script ?? name}" script to package.json`
287
299
  : gate?.kind === 'command'
@@ -302,11 +314,14 @@ function gateRemedy(gate, { existing = false, runner = null } = {}) {
302
314
  /**
303
315
  * Is this runner one where `npm run` is not how a gate is spelled?
304
316
  *
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.
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.)
307
321
  */
308
322
  function isForeignRunner(runner) {
309
- return runner != null && runner !== 'npm';
323
+ if (runner == null) return false;
324
+ return !['npm', 'pnpm', 'yarn', 'bun'].includes(runner);
310
325
  }
311
326
 
312
327
  /** How a reader would name the runner in a sentence. */
@@ -316,6 +331,9 @@ function runnerName(runner) {
316
331
  none: 'no recognised runner',
317
332
  gradle: 'Gradle',
318
333
  python: 'Python',
334
+ npm: 'npm',
335
+ pnpm: 'pnpm',
336
+ yarn: 'yarn',
319
337
  bun: 'bun',
320
338
  cargo: 'Cargo',
321
339
  go: 'Go',
@@ -383,14 +401,15 @@ export function planGates(stack = [], capability = null, facts = []) {
383
401
  */
384
402
  function ecosystemCommands(facts = [], capability = null) {
385
403
  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 {};
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 {};
394
413
  const present = new Set(facts);
395
414
  const commands = {};
396
415
  for (const eco of ECOSYSTEMS) {
@@ -728,6 +747,23 @@ export function declaredShared(manifest) {
728
747
 
729
748
  export const GENERATED_KEYS = new Set(['$comment', 'projection']);
730
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
+
731
767
  /**
732
768
  * A manifest with this tool's own fields replaced by what this build writes, and
733
769
  * nothing else touched.
@@ -738,13 +774,22 @@ export const GENERATED_KEYS = new Set(['$comment', 'projection']);
738
774
  * repo chose is never compared" is a promise about the repair too, not only about
739
775
  * the report, and a repair is exactly where it would be broken.
740
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
+ *
741
780
  * @param {object} manifest the repository's manifest, as parsed
742
781
  * @param {object} expected `buildManifest`'s output, from the call `manifestDrift` made
743
782
  * @returns {object} a new manifest; the argument is not mutated
744
783
  */
745
784
  export function withGenerated(manifest, expected) {
746
785
  const patched = { ...manifest };
747
- 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
+ }
748
793
  return patched;
749
794
  }
750
795
 
@@ -787,9 +832,14 @@ export async function manifestDrift(root, stack, sha) {
787
832
  // `[...GENERATED_KEYS]`, and not `Object.keys(expected)`: the question is which
788
833
  // fields are ours, and the answer is the set above. Iterating the expected object
789
834
  // 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
- );
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
+ });
793
843
  if (stale.length > 0) {
794
844
  return {
795
845
  file: MANIFEST,
@@ -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