simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -18,9 +18,8 @@
18
18
  // commands owns which file would be learning about ownership twice to learn nothing
19
19
  // new, and the next .github file would arrive with a third command to remember.
20
20
 
21
- import { mkdir, readFile } from 'node:fs/promises';
22
- import { dirname } from 'node:path';
23
- import { commitJournal, journaledWriteFile } from '../journal.mjs';
21
+ import { readFile } from 'node:fs/promises';
22
+ import { applyPlan, change } from '../write/plan.mjs';
24
23
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
25
24
 
26
25
  import { EXIT, emit, emitJson, isJson, note, writeMode } from '../output/index.mjs';
@@ -39,7 +38,7 @@ import { commandRoot } from '../repo-root.mjs';
39
38
  function targets() {
40
39
  return [
41
40
  { key: 'workflow', file: WORKFLOW_FILE, plan: planWorkflow, render: () => renderWorkflow({ own: true }) },
42
- { key: 'dependabot', file: DEPENDABOT_FILE, plan: planDependabot, render: (plan) => renderDependabot(plan.facts ?? [], { stack: plan.stack ?? [], own: true }) },
41
+ { key: 'dependabot', file: DEPENDABOT_FILE, plan: planDependabot, render: (plan) => renderDependabot(plan.facts ?? [], { stack: plan.stack ?? [], own: true, root: plan.root }) },
43
42
  ];
44
43
  }
45
44
 
@@ -153,19 +152,18 @@ export async function ciCommand(positional, flags) {
153
152
  return EXIT.OK;
154
153
  }
155
154
 
156
- // Journaled so `undo` restores this run (R49).
157
- const journal = [];
155
+ // Journaled so `undo` restores this run (R49). The renderer takes the plan because
156
+ // detection-driven content is a function of this repo's facts: a dependabot config
157
+ // rendered without them would watch nothing, which is the same silent-nothing the
158
+ // detection ruling exists to stop.
159
+ const changes = [];
158
160
  for (const { key, plan, render } of targets()) {
159
161
  const p = plans.find((x) => x.key === key);
160
162
  if (p.action === 'unchanged') continue;
161
- await mkdir(dirname(p.path), { recursive: true });
162
- // The renderer takes the plan because detection-driven content is a function of
163
- // this repo's facts: a dependabot config rendered without them would watch
164
- // nothing, which is the same silent-nothing the detection ruling exists to stop.
165
- await journaledWriteFile(root, journal, p.path, render(p));
166
- note(`wrote ${p.file} (${p.action}; the template and this copy now agree)`);
163
+ changes.push(change({ path: p.path, before: await readBefore(p.path), after: render(p), owner: 'ci', reason: 'the template and this copy now agree' }));
167
164
  }
168
- await commitJournal(root, 'ci', journal);
165
+ await applyPlan(root, changes, { mode, label: 'ci' });
166
+ for (const c of changes) note(`wrote ${c.path.replace(`${root}/`, '')} (${c.action}; ${c.reason})`);
169
167
  if (isJson(flags)) {
170
168
  emitJson(
171
169
  {
@@ -0,0 +1,88 @@
1
+ // `simt docs readme` — put the README's command list back in step with the spec.
2
+ //
3
+ // Shape copied from `ci` and `ignore`: the plan is printed first, nothing is written
4
+ // without `--yes`, `--diff` shows the same plan as a unified diff, and `undo` restores
5
+ // it. The rendering itself lives in `src/docs/commands.mjs` so a test can compare the
6
+ // README against it without running this command.
7
+
8
+ import { join } from 'node:path';
9
+
10
+ import { EXIT, fail, present, writeMode } from '../output/index.mjs';
11
+ import { CLI } from '../name.mjs';
12
+ import { applyPlan, change } from '../write/plan.mjs';
13
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
14
+ import { commandRoot } from '../repo-root.mjs';
15
+ import { BEGIN, END, README_FILE, renderCommandBlock, replaceBlock } from '../docs/commands.mjs';
16
+
17
+ export async function docsCommand(sub, args, flags) {
18
+ if (sub !== 'readme') {
19
+ return fail(`unknown docs page \`${sub}\``, EXIT.USAGE, `run: ${CLI} docs readme`);
20
+ }
21
+ const root = commandRoot(flags);
22
+ const path = join(root, README_FILE);
23
+
24
+ let before;
25
+ try {
26
+ before = await readBefore(path);
27
+ } catch (error) {
28
+ return fail(
29
+ `${README_FILE} could not be read: ${error.message}`,
30
+ EXIT.FAIL,
31
+ `fix the file's permissions, then re-run: ${CLI} docs readme`,
32
+ );
33
+ }
34
+ if (before === null) {
35
+ return fail(`no ${README_FILE} in ${root}`, EXIT.FAIL, `run this where ${README_FILE} is`);
36
+ }
37
+
38
+ const after = replaceBlock(before, renderCommandBlock());
39
+ if (after === null) {
40
+ return fail(
41
+ `${README_FILE} carries no ${BEGIN} ... ${END} region`,
42
+ EXIT.FAIL,
43
+ `put the two marker lines where the command list belongs, then: ${CLI} docs readme --yes`,
44
+ );
45
+ }
46
+
47
+ const mode = writeMode(flags);
48
+ const action = after === before ? 'unchanged' : 'write';
49
+ const payload = {
50
+ ok: true,
51
+ command: 'docs',
52
+ page: 'readme',
53
+ root,
54
+ file: README_FILE,
55
+ action,
56
+ written: mode.apply && action === 'write',
57
+ };
58
+
59
+ if (action === 'unchanged') {
60
+ present(payload, () => `current ${README_FILE}`, flags);
61
+ return EXIT.OK;
62
+ }
63
+
64
+ const diff = [{ path: README_FILE, before, after }];
65
+ if (!mode.apply) {
66
+ // A rehearsal says which flag it is waiting for, because "not written" is the same
67
+ // sentence for `--dry-run` and for a missing `--yes`, and the two have different fixes.
68
+ present(
69
+ { ...payload, ...diffField(diff, flags) },
70
+ () => `plan ${README_FILE}: the command list follows the spec${mode.dryRun ? ' (--dry-run)' : ' (re-run with --yes to write)'}`,
71
+ flags,
72
+ );
73
+ emitDiffs(diff, flags);
74
+ return EXIT.OK;
75
+ }
76
+
77
+ await applyPlan(root, [change({ path, before, after, owner: 'docs readme', reason: 'the command list follows the spec' })], {
78
+ mode,
79
+ label: 'docs readme',
80
+ });
81
+ present(
82
+ { ...payload, ...diffField(diff, flags) },
83
+ () => `write ${README_FILE} (${Buffer.byteLength(after)} bytes)`,
84
+ flags,
85
+ );
86
+ emitDiffs(diff, flags);
87
+ return EXIT.OK;
88
+ }
@@ -13,12 +13,15 @@ import { promisify } from 'node:util';
13
13
 
14
14
  import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
15
15
  import { CLI } from '../name.mjs';
16
- import { detectTestRunner } from '../bench/mine.mjs';
16
+ import { detectTestRunner } from '../testing/runners.mjs';
17
17
  import { compactOutput } from '../index/compact.mjs';
18
- import { readImportEdges, selectTests } from '../index/gate.mjs';
18
+ import { coverage } from '../index/coverage.mjs';
19
+ import { readImportEdges } from '../index/gate.mjs';
19
20
  import { langOf, packageOf } from '../index/store.mjs';
20
21
  import { localRel } from '../state.mjs';
21
22
  import { commandRoot } from '../repo-root.mjs';
23
+ import { gitPaths } from '../sys/git.mjs';
24
+ import { cmp } from '../util/sort.mjs';
22
25
 
23
26
  const exec = promisify(execFile);
24
27
 
@@ -65,7 +68,7 @@ export async function gateCommand(args, flags) {
65
68
  const files = new Set(listed.files);
66
69
  for (const file of changed) files.add(file);
67
70
  const edges = await readImportEdges(root, files);
68
- const selected = selectTests(changed, edges);
71
+ const selected = coverage({ imports: edges }, changed);
69
72
  // Q40: the selection explains itself. `unmatched` names explicit paths the
70
73
  // tracked tree does not carry (untracked but present, so the run proceeds);
71
74
  // `graph` is `unresolved` when a language has edges but none of them reaches
@@ -199,8 +202,7 @@ async function tracked(root) {
199
202
  try {
200
203
  // `-z`: without it git C-quotes a non-ASCII path (`src/\303\274n...`) and
201
204
  // the reader's real filename never matches it (review 0003 Q39).
202
- const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
203
- return { files: stdout.split('\0').filter(Boolean) };
205
+ return { files: await gitPaths(root, ['ls-files']) };
204
206
  } catch (error) {
205
207
  return {
206
208
  error: fail(
@@ -214,9 +216,9 @@ async function tracked(root) {
214
216
 
215
217
  async function changedFiles(root) {
216
218
  try {
217
- const { stdout } = await exec('git', ['diff', '--name-only', '-z', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
218
- const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 8 << 20 });
219
- return { files: [...new Set([...stdout.split('\0'), ...untracked.stdout.split('\0')].filter(Boolean))].sort() };
219
+ const files = await gitPaths(root, ['diff', '--name-only', 'HEAD'], { maxBuffer: 8 << 20 });
220
+ const untracked = await gitPaths(root, ['ls-files', '--others', '--exclude-standard'], { maxBuffer: 8 << 20 });
221
+ return { files: [...new Set([...files, ...untracked])].sort() };
220
222
  } catch (error) {
221
223
  return {
222
224
  error: fail(
@@ -228,8 +230,3 @@ async function changedFiles(root) {
228
230
  }
229
231
  }
230
232
 
231
- function cmp(a, b) {
232
- if (a < b) return -1;
233
- if (a > b) return 1;
234
- return 0;
235
- }
@@ -100,13 +100,13 @@ async function scan(root, { mcp = false } = {}) {
100
100
  const rows = [];
101
101
  for (const s of SURFACES) {
102
102
  for (const dir of s.configDirs ?? []) {
103
- if (await present(root, dir)) {
103
+ if (await isDirectory(root, dir)) {
104
104
  rows.push(row(dir, s.label, { id: s.id, verified: s.verified ?? null, readsAgentsMd: s.readsAgentsMd, rule: s.rule }));
105
105
  }
106
106
  }
107
107
  }
108
108
  for (const c of CANDIDATE_DIRS) {
109
- if (await present(root, c.dir)) {
109
+ if (await isDirectory(root, c.dir)) {
110
110
  rows.push(row(c.dir, c.harness, { readsAgentsMd: c.readsAgentsMd ?? null, readsAgentsMdNote: c.readsAgentsMdNote ?? null, verified: c.verified ?? null }));
111
111
  }
112
112
  }
@@ -115,7 +115,7 @@ async function scan(root, { mcp = false } = {}) {
115
115
  return { root, rows };
116
116
  }
117
117
 
118
- async function present(root, dir) {
118
+ async function isDirectory(root, dir) {
119
119
  try {
120
120
  return (await stat(join(root, dir))).isDirectory();
121
121
  } catch {
@@ -4,6 +4,9 @@ import { parse, rawJsonFlag } from '../argv.mjs';
4
4
  import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
5
5
  import { SESSION_ENV } from '../agenthooks/policy.mjs';
6
6
  import { DEFAULT_DIRECTORIES, DEFAULT_TEMPLATE } from '../plan/template.mjs';
7
+ import { commandRoot } from '../repo-root.mjs';
8
+ import { readManifest } from '../projection/manifest.mjs';
9
+ import { assertVersionPin } from '../package-version.mjs';
7
10
 
8
11
  const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
9
12
  (dry-run unless --yes; conflicts block without
@@ -242,6 +245,7 @@ ${CLI} pack publish [dir] [--yes]
242
245
  publish an npm-sourced pack to the registry
243
246
  (dry-run unless --yes; skips a version already live;
244
247
  a git-sourced pack names the tag-and-push instead)
248
+ ${CLI} docs readme --yes render the README command list from the spec
245
249
  ${CLI} mcp the read-only surface as MCP tools over stdio
246
250
  (--list prints it without reading stdin; init, sync,
247
251
  upgrade and the installers stay CLI-only by design)`;
@@ -294,6 +298,8 @@ const FLAGS = `--json structured output (compact; one document,
294
298
  --accept-conflicts init: adopt rules that collide with hand-written ones
295
299
  --backup init: tag the pre-adoption commit and branch onto
296
300
  simt-dev/adopt before writing (the undo path)
301
+ --no-agents init: skip the agent files and the agent-hooks in one flag;
302
+ the standalone agents / agent-hooks install commands remain
297
303
  --spec <name> graph lint: also check every skill against a named
298
304
  specification (one exists: agentskills)
299
305
  --user harness detect: also report the directories in your home
@@ -313,7 +319,9 @@ const FLAGS = `--json structured output (compact; one document,
313
319
  --harness <id> bench run: adapter to invoke (claude, codex, gemini, opencode,
314
320
  cursor, aider, gold, noop; default: gold);
315
321
  agent-hook: which dialect to answer in (claude, cursor,
316
- codex, antigravity)
322
+ codex, antigravity); agent-hooks / agents install: which
323
+ reader to write (one, not all); init: narrows the agent
324
+ files and the agent-hooks to one reader
317
325
  --model <id> bench run: model identifier recorded and passed to the adapter
318
326
  --reps <n> bench run: repetitions per task and arm (default: 3)
319
327
  --regrade <run> bench run: re-grade a stored run from its archived agent patch
@@ -321,6 +329,8 @@ const FLAGS = `--json structured output (compact; one document,
321
329
  --test <cmd> plan done / slice finish: the test command that closes the row
322
330
  --state <name> plan done: target state (default: Complete)
323
331
  --type <name> slice finish: the Conventional Commits type (default: feat)
332
+ --id <name> slice start: the slice id when -- starts one from a prompt
333
+ (default: a slug of the prompt text)
324
334
  --ttl <seconds> lease: how long a claim lasts (default: 3600)
325
335
  --level <name> map: detail level (outline, names, signatures)
326
336
  --supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
@@ -385,6 +395,13 @@ export async function help(argv, dispatched) {
385
395
  emitJson(SPEC.map(specRow));
386
396
  return 0;
387
397
  }
398
+ // The standard page is where a reader asks "what can this do, and where do I start".
399
+ // A repository that has not been adopted, or that pins a version this binary is not,
400
+ // has a prerequisite worth stating before the catalogue: no manifest means init is the
401
+ // first move, a stale pin means sync is the fix. Fully ready, the page stands on its
402
+ // own and the banner is nothing.
403
+ const banner = await prerequisiteBanner(flags, positional);
404
+ const bannerLines = banner === null ? [] : ['', banner, ''];
388
405
  // `--all` prints every command and every flag; the default page lists the core tier and
389
406
  // says where the rest is. A reader asking "what can this do" needs the shape, not 23 KB
390
407
  // of flag reference (review 0003 Q61).
@@ -394,7 +411,7 @@ export async function help(argv, dispatched) {
394
411
  'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
395
412
  '',
396
413
  `usage: ${CLI} <command> [args] [flags]`,
397
- '',
414
+ ...bannerLines,
398
415
  'commands:',
399
416
  COMMANDS,
400
417
  '',
@@ -423,7 +440,7 @@ export async function help(argv, dispatched) {
423
440
  'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
424
441
  '',
425
442
  `usage: ${CLI} <command> [args] [flags]`,
426
- '',
443
+ ...bannerLines,
427
444
  'commands:',
428
445
  ...[...groups].map(([group, subs]) => ` ${CLI} ${group} <${subs.join('|')}>`),
429
446
  ...bare.map((row) => ` ${CLI} ${row.path.padEnd(8)} ${row.summary.length > 46 ? `${row.summary.slice(0, 43)}...` : row.summary}`),
@@ -435,6 +452,30 @@ export async function help(argv, dispatched) {
435
452
  return 0;
436
453
  }
437
454
 
455
+ /**
456
+ * The prerequisite line for the standard help page, or null when the repository is fully
457
+ * ready. No manifest (or one that cannot be read) means the adoption has not happened and
458
+ * `init` is the first move; a manifest that pins a version the running binary is not means
459
+ * `sync` is the fix. These are the two states in which a reader opening help is about to
460
+ * run a command that would fail in a predictable way, so the page says so before it lists
461
+ * the commands.
462
+ */
463
+ async function prerequisiteBanner(flags, positional) {
464
+ const root = commandRoot(flags, positional);
465
+ const { manifest, exists, error } = await readManifest(root);
466
+ if (!exists) {
467
+ return `prerequisite no ${MANIFEST_FILE} here; run: ${CLI} init <path>`;
468
+ }
469
+ if (error !== null) {
470
+ return `prerequisite ${MANIFEST_FILE} cannot be read (${error}); run: ${CLI} init <path>`;
471
+ }
472
+ const stale = assertVersionPin(manifest);
473
+ if (stale !== null) {
474
+ return `prerequisite ${stale}; run: ${CLI} sync`;
475
+ }
476
+ return null;
477
+ }
478
+
438
479
  async function version() {
439
480
  const { describeVersion } = await import('../package-version.mjs');
440
481
  return `${describeVersion()}\n`;
@@ -14,7 +14,8 @@
14
14
 
15
15
  import { mkdir, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
16
16
  import { repoContext } from '../context.mjs';
17
- import { commitJournal, journaledGitConfig, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
17
+ import { commitJournal, journaledGitConfig, journaledRemoveFile } from '../journal.mjs';
18
+ import { applyPlan, change } from '../write/plan.mjs';
18
19
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
19
20
  import { invalidateCheckCache } from '../status.mjs';
20
21
  import { isAbsolute, join, relative } from 'node:path';
@@ -23,13 +24,13 @@ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/i
23
24
  import { MANIFEST, readManifest } from '../projection/manifest.mjs';
24
25
  import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_DIR_LEGACY, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
25
26
  import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
26
- import { GATES } from '../hooks/gates.mjs';
27
- import { gateNames } from '../hooks/gates.mjs';
27
+ import { gateNames, getGate } from '../hooks/gates.mjs';
28
28
  import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
29
29
  import { CLI } from '../name.mjs';
30
30
  import { MANIFEST_FILE } from '../name.mjs';
31
31
  import { packageVersion, hasNpm } from '../package-version.mjs';
32
32
  import { commandRoot } from '../repo-root.mjs';
33
+ import { readIfExists } from '../util/fs.mjs';
33
34
 
34
35
  export async function hooksCommand(sub, args, flags) {
35
36
  const root = commandRoot(flags, args);
@@ -374,26 +375,31 @@ export async function applyHooksInstall(root, planned, journal) {
374
375
  const wrote = [];
375
376
  const removed = [];
376
377
  await mkdir(join(root, HOOKS_DIR), { recursive: true });
378
+ const hookChanges = [];
377
379
  for (const event of wanted) {
378
380
  const path = join(root, fileFor(event));
379
- await journaledWriteFile(root, journal, path, renderShim(event, { npm, manager, version }), { mode: 0o755 });
381
+ hookChanges.push(change({ path, before: await readBefore(path), after: renderShim(event, { npm, manager, version }), owner: 'hooks install', reason: event, mode: 0o755 }));
380
382
  wrote.push(fileFor(event));
381
383
  }
382
384
  for (const c of chained) {
383
385
  const path = join(root, fileFor(c.event));
384
- await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target), { mode: 0o755 });
386
+ hookChanges.push(change({ path, before: await readBefore(path), after: renderChainedShim(c.event, c.target), owner: 'hooks install', reason: c.event, mode: 0o755 }));
385
387
  wrote.push(fileFor(c.event));
386
388
  }
387
389
  for (const h of obsolete) {
388
- await journaledRemoveFile(root, journal, join(root, fileFor(h.event)));
390
+ const obsoletePath = join(root, fileFor(h.event));
391
+ hookChanges.push(
392
+ change({ path: obsoletePath, kind: 'remove', before: await readBefore(obsoletePath), after: null, owner: 'hooks install', reason: h.event }),
393
+ );
389
394
  wrote.push(fileFor(h.event));
390
395
  }
391
396
  // After the chain loop, so a chained commit-msg cannot replace the claim check.
392
397
  if (evidenceSource) {
393
398
  const path = join(root, evidenceFile);
394
- await journaledWriteFile(root, journal, path, evidenceSource, { mode: 0o755 });
399
+ hookChanges.push(change({ path, before: await readBefore(path), after: evidenceSource, owner: 'hooks install', reason: 'the evidence shim', mode: 0o755 }));
395
400
  wrote.push(evidenceFile);
396
401
  }
402
+ await applyPlan(root, hookChanges, { mode: { apply: true }, journal, commit: false });
397
403
  // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
398
404
  // pointing at a directory of no shims stops the repository's own hooks - the ones in
399
405
  // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
@@ -416,6 +422,7 @@ export async function applyHooksInstall(root, planned, journal) {
416
422
  await rm(join(root, HOOKS_DIR_LEGACY), { recursive: true });
417
423
  }
418
424
  return { wrote, removed };
425
+
419
426
  }
420
427
 
421
428
  /**
@@ -749,7 +756,7 @@ function describeGates(manifest, key) {
749
756
  const list = manifest?.gates?.[key];
750
757
  if (!Array.isArray(list) || list.length === 0) return '(none declared)';
751
758
  return list.map((name) => {
752
- const gate = GATES[name];
759
+ const gate = getGate(name, manifest?.gates?.commands);
753
760
  return gate ? name : `${name} (UNKNOWN - the hook will fail)`;
754
761
  }).join(', ');
755
762
  }
@@ -818,7 +825,7 @@ async function hooksStatus(root, flags, ctx = repoContext(root)) {
818
825
  lines.push('', 'would run:');
819
826
  for (const key of ['preCommit', 'prePush']) {
820
827
  for (const name of manifest?.gates?.[key] ?? []) {
821
- const known = GATES[name];
828
+ const known = getGate(name, commands);
822
829
  // A declared command wins for every kind, so the status must say so too —
823
830
  // printing "run script" for a gate the dispatcher will shell out to `./gradlew`
824
831
  // for is the same one-question-two-answers defect ADR-018 was written about.
@@ -837,7 +844,7 @@ async function hooksStatus(root, flags, ctx = repoContext(root)) {
837
844
  if (undeclared.length > 0) {
838
845
  lines.push('', `declared but never run: ${undeclared.join(', ')}`);
839
846
  }
840
- const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !GATES[n]);
847
+ const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !getGate(n, commands));
841
848
  if (unknown.length > 0) {
842
849
  lines.push('', `unknown gates (the hook will fail): ${unknown.join(', ')}`);
843
850
  lines.push(`known gates: ${gateNames().join(', ')}`);
@@ -869,8 +876,9 @@ function collectProblems({ exists, current, installed, manifest, foreign = [] })
869
876
  if (exists) {
870
877
  for (const key of ['preCommit', 'prePush']) {
871
878
  for (const name of manifest?.gates?.[key] ?? []) {
872
- if (!GATES[name]) problems.push(`gate "${name}" in ${key} is not a known gate`);
873
- else if (GATES[name].kind === 'command' && !manifest?.gates?.commands?.[name]) {
879
+ const gate = getGate(name, manifest?.gates?.commands);
880
+ if (!gate) problems.push(`gate "${name}" in ${key} is not a known gate`);
881
+ else if (gate.kind === 'command' && !manifest?.gates?.commands?.[name]) {
874
882
  problems.push(`gate "${name}" in ${key} is a command gate with no declared command; the hook will fail`);
875
883
  }
876
884
  }
@@ -878,11 +886,3 @@ function collectProblems({ exists, current, installed, manifest, foreign = [] })
878
886
  }
879
887
  return problems;
880
888
  }
881
-
882
- async function readIfExists(path) {
883
- try {
884
- return await readFile(path, 'utf8');
885
- } catch {
886
- return null;
887
- }
888
- }
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
 
12
12
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
13
13
  import { CLI } from '../name.mjs';
14
- import { commitJournal, journaledWriteFile } from '../journal.mjs';
14
+ import { applyPlan, change } from '../write/plan.mjs';
15
15
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
16
16
  import { CLAUDE_SETTINGS, IGNORE_FILE, parseIgnoreList, planIgnore } from '../ignore.mjs';
17
17
  import { commandRoot } from '../repo-root.mjs';
@@ -125,12 +125,16 @@ export async function ignoreCommand(args, flags) {
125
125
 
126
126
  // Journaled so `undo` restores this run (R49). Every note writes to stderr,
127
127
  // so the `--json` document remains the only bytes on stdout.
128
- const journal = [];
129
- for (const t of changed) {
130
- await journaledWriteFile(root, journal, join(root, t.path), t.after);
131
- note(`wrote ${t.path} (${t.action})`);
132
- }
133
- await commitJournal(root, 'ignore', journal);
128
+ await applyPlan(
129
+ root,
130
+ await Promise.all(
131
+ changed.map(async (t) =>
132
+ change({ path: t.path, before: await readBefore(join(root, t.path)), after: t.after, owner: 'ignore', reason: t.action }),
133
+ ),
134
+ ),
135
+ { mode: { apply: true }, label: 'ignore' },
136
+ );
137
+ for (const t of changed) note(`wrote ${t.path} (${t.action})`);
134
138
 
135
139
  if (isJson(flags)) emitJson(payload(true));
136
140
  // After the report: the report says what, the diff shows it (R50).
@@ -4,10 +4,8 @@
4
4
  // the file, the CODEOWNERS line, and — when a workspace is configured —
5
5
  // another repository that references a contract this change touches.
6
6
 
7
- import { execFile } from 'node:child_process';
8
7
  import { readFile } from 'node:fs/promises';
9
8
  import { basename, join, resolve } from 'node:path';
10
- import { promisify } from 'node:util';
11
9
 
12
10
  import { EXIT, fail, present } from '../output/index.mjs';
13
11
  import { CLI } from '../name.mjs';
@@ -22,12 +20,12 @@ import {
22
20
  ownersOf,
23
21
  relatedContracts,
24
22
  } from '../index/impact.mjs';
25
- import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
23
+ import { governs, sourceRoots } from '../index/govern.mjs';
26
24
  import { decisionRecords, ruleScopes } from './why.mjs';
27
25
  import { resolveWorkspace } from '../workspace.mjs';
28
26
  import { commandRoot } from '../repo-root.mjs';
27
+ import { gitPaths } from '../sys/git.mjs';
29
28
 
30
- const exec = promisify(execFile);
31
29
  const TEXT_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'md', 'toml', 'xml', 'py', 'go', 'kt', 'java', 'rs']);
32
30
  const READ_CAP = 256 * 1024;
33
31
  const FILE_CAP = 2000;
@@ -88,8 +86,7 @@ async function governance(root, file, files, commands) {
88
86
  const records = await decisionRecords(root);
89
87
  const rules = await ruleScopes(root);
90
88
  const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
91
- const { hits } = decisionHits(records, file, ctx);
92
- return sortHits([...hits, ...scopeHits(file, { rules })]);
89
+ return governs(file, { records, rules, ctx });
93
90
  }
94
91
 
95
92
  async function readOwners(root, file) {
@@ -190,8 +187,7 @@ async function tracked(root) {
190
187
  try {
191
188
  // `-z`: without it git C-quotes a non-ASCII path and the reader's real
192
189
  // filename never matches it (review 0003 Q39).
193
- const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
194
- return { files: stdout.split('\0').filter(Boolean) };
190
+ return { files: await gitPaths(root, ['ls-files']) };
195
191
  } catch (error) {
196
192
  return {
197
193
  error: fail(
@@ -1,6 +1,6 @@
1
1
  // Command registry and dispatch.
2
2
  //
3
- // Every command named in docs/plans/0002-simt-dev-cli.md section 5 is registered
3
+ // Every command named in docs/research/0002-simt-dev-cli.md section 5 is registered
4
4
  // here from day one. A command that is declared but not yet implemented exits
5
5
  // UNSUPPORTED with a pointer to its implementation step; it never silently
6
6
  // succeeds. An agent that calls `simt compare` on an unreleased build must get a
@@ -21,9 +21,10 @@ import {
21
21
  jsonFailMode,
22
22
  jsonIndentDefault,
23
23
  maxBytesLimit,
24
+ withOutputState,
24
25
  } from '../output/index.mjs';
25
26
  import { help } from './help.mjs';
26
- import { GROUP_DEFAULTS, SPEC, SPEC_PATHS } from './spec.mjs';
27
+ import { ALIASES, GROUP_DEFAULTS, SPEC, SPEC_PATHS } from './spec.mjs';
27
28
  import { parse, rawJsonFlag } from '../argv.mjs';
28
29
  import { CLI } from '../name.mjs';
29
30
  import { packageVersion } from '../package-version.mjs';
@@ -44,7 +45,10 @@ const DECLARED = new Map(SPEC.map((row) => [row.path, new Set(row.flags ?? [])])
44
45
  /** path -> the values a flag of that path accepts, where the set is closed. */
45
46
  const VALUES = new Map(SPEC.map((row) => [row.path, row.values ?? {}]));
46
47
 
47
- const GLOBAL_FLAGS = new Set(['root', 'json', 'pretty', 'help', 'version', 'maxBytes']);
48
+ // `dashdash` is the parser's record that `--` appeared, not a flag any reader typed:
49
+ // it is an envelope fact like `root`, so the per-command declaration check skips it
50
+ // and lets the one command that reads it (slice start) act on it.
51
+ export const GLOBAL_FLAGS = new Set(['root', 'json', 'pretty', 'help', 'version', 'maxBytes', 'dashdash']);
48
52
 
49
53
  /**
50
54
  * Every command this binary dispatches, in one place.
@@ -149,6 +153,12 @@ export const IMPLEMENTED = {
149
153
  // Plan 0003 R54. A bare command: paths claim, no paths list, so the argument itself
150
154
  // is the mode and there is no group to resolve.
151
155
  lease: async () => (await import('./lease.mjs')).leaseCommand,
156
+ // Review 0003 Q60. The README's command list is the last copy of the command table
157
+ // the spec owns; the renderer lives in src/docs so a test can hold the file to it.
158
+ 'docs readme': async () => {
159
+ const { docsCommand } = await import('./docs.mjs');
160
+ return (args, flags) => docsCommand('readme', args, flags);
161
+ },
152
162
  distill: async () => (await import('./distill.mjs')).distillCommand,
153
163
  // R25 machinery needs the runnable command names for Affects command tokens,
154
164
  // same wiring `why` and `impact` get through their lambdas.
@@ -245,8 +255,25 @@ export const IMPLEMENTED = {
245
255
  },
246
256
  };
247
257
 
258
+ /**
259
+ * Resolve one command path to its handler, its declared flags and its closed value sets.
260
+ *
261
+ * The library surface needs the same answer `dispatch` gets and must not build a second
262
+ * registry to get it: a command reachable from the CLI and absent from `run` is the drift
263
+ * `test/spec.test.mjs` exists to catch, and it would be invisible here (review 0003 Q63).
264
+ */
265
+ export async function resolveCommand(path) {
266
+ const load = IMPLEMENTED[path];
267
+ if (!load) return null;
268
+ return {
269
+ run: await load(),
270
+ declared: DECLARED.get(path) ?? new Set(),
271
+ values: VALUES.get(path) ?? {},
272
+ };
273
+ }
274
+
248
275
  /** The release that turns the tolerated flag into a usage error (review 0003 Q62). */
249
- export const DEPRECATION_RELEASE = '0.8.0';
276
+ export const DEPRECATION_RELEASE = '0.9.0';
250
277
 
251
278
  /**
252
279
  * Whether a tolerance that ends at `removedIn` has ended, comparing the way versions
@@ -275,7 +302,18 @@ export function refusesAt(removedIn, version) {
275
302
  */
276
303
  export const COMMANDS = SPEC_PATHS;
277
304
 
305
+ /**
306
+ * Dispatch one argv.
307
+ *
308
+ * The state is per call: an MCP server and the tests both dispatch more than once in a
309
+ * process, and a shared envelope let one run's ceiling, notices and command path land in
310
+ * another run's document (review 0003 Q63/Q65).
311
+ */
278
312
  export async function dispatch(argv) {
313
+ return withOutputState(() => dispatchInState(argv));
314
+ }
315
+
316
+ async function dispatchInState(argv) {
279
317
  const { flags, positional, parseError } = parseOrExit(argv);
280
318
  // One run, one set of notices: a process can dispatch repeatedly (review 0003 Q62).
281
319
  clearWarnings();
@@ -383,10 +421,14 @@ export async function dispatch(argv) {
383
421
  * Normalise argv into a registry key, or null when unrecognised.
384
422
  * A group with an omitted subcommand resolves to that group's default, so
385
423
  * `simt skills` is `simt skills index`.
424
+ *
425
+ * An alias resolves here rather than as a second registry entry: the spec path stays the
426
+ * one dispatch key, so an alias cannot load a different handler or escape the flag
427
+ * declaration the path carries (review 0003 Q81).
386
428
  */
387
429
  function commandPath(first, second) {
388
430
  if (first === undefined) return null;
389
- if (!GROUPS.has(first)) return first;
431
+ if (!GROUPS.has(first)) return ALIASES.get(first) ?? first;
390
432
 
391
433
  const group = GROUPS.get(first);
392
434
  if (second === undefined) return `${first} ${group}`;
@@ -400,10 +442,8 @@ function commandPath(first, second) {
400
442
  // `doctor` is not a group and this branch is only reached when `GROUPS.has(first)`.
401
443
  // Two lists of the same fact is the defect S15 was about in the help text.
402
444
  const key = `${first} ${second}`;
403
- if (!(key in IMPLEMENTED)) {
404
- return null;
405
- }
406
- return key;
445
+ if (key in IMPLEMENTED) return key;
446
+ return ALIASES.get(key) ?? null;
407
447
  }
408
448
 
409
449
  function parseOrExit(argv) {