simt-dev 0.7.0 → 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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. 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
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { basename, resolve } from 'node:path';
23
23
 
24
- import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
24
+ import { EXIT, fail, note, present } from '../output/index.mjs';
25
25
  import { provenanceDigest } from '../projection/project.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
27
  import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
@@ -78,50 +78,50 @@ export async function compareCommand(positional, flags) {
78
78
  directories: found.directories.length,
79
79
  });
80
80
 
81
- if (isJson(flags)) {
82
- emitJson({
83
- ok: !rows.some((r) => r.problems.length > 0),
84
- command: 'compare',
85
- root: summary.root,
86
- shape: summary.shape,
87
- source: summary.source,
88
- expectedSha: summary.expectedSha,
89
- // Spread, not a hand-listed copy. A copy is a second place to forget: a field
90
- // added to `summarise` renders in the text output and silently vanishes from
91
- // `--json`, which is the combination automation trusts most. That is not
92
- // hypothetical — `withOwnHooks` and `staleRuleFiles` both did it, and both
93
- // were caught only because a test asserts the two shapes agree.
94
- summary: {
95
- repos: summary.repos,
96
- adopted: summary.adopted,
97
- unmanaged: summary.unmanaged,
98
- broken: summary.broken,
99
- drifted: summary.drifted,
100
- current: summary.current,
101
- noRegion: summary.noRegion,
102
- malformed: summary.malformed,
103
- staleRuleFiles: summary.staleRuleFiles,
104
- packageRegions: summary.packageRegions,
105
- stalePackages: summary.stalePackages,
106
- hooksRouted: summary.hooksRouted,
107
- withProblems: summary.withProblems,
108
- withOwnHooks: summary.withOwnHooks,
109
- shas: summary.shas,
110
- },
111
- rows,
112
- learning,
113
- worktrees: found.worktrees.map((w) => ({
114
- name: w.name,
115
- path: w.path,
116
- owner: w.ownerName,
117
- ownerMatchesDirName: w.ownerMatches,
118
- })),
119
- directories: found.directories.map((d) => basename(d)),
120
- });
81
+ const payload = {
82
+ ok: !rows.some((r) => r.problems.length > 0),
83
+ command: 'compare',
84
+ root: summary.root,
85
+ shape: summary.shape,
86
+ source: summary.source,
87
+ expectedSha: summary.expectedSha,
88
+ // Spread, not a hand-listed copy. A copy is a second place to forget: a field
89
+ // added to `summarise` renders in the text output and silently vanishes from
90
+ // `--json`, which is the combination automation trusts most. That is not
91
+ // hypothetical — `withOwnHooks` and `staleRuleFiles` both did it, and both
92
+ // were caught only because a test asserts the two shapes agree.
93
+ summary: {
94
+ repos: summary.repos,
95
+ adopted: summary.adopted,
96
+ unmanaged: summary.unmanaged,
97
+ broken: summary.broken,
98
+ drifted: summary.drifted,
99
+ current: summary.current,
100
+ noRegion: summary.noRegion,
101
+ malformed: summary.malformed,
102
+ staleRuleFiles: summary.staleRuleFiles,
103
+ packageRegions: summary.packageRegions,
104
+ stalePackages: summary.stalePackages,
105
+ hooksRouted: summary.hooksRouted,
106
+ withProblems: summary.withProblems,
107
+ withOwnHooks: summary.withOwnHooks,
108
+ shas: summary.shas,
109
+ },
110
+ rows,
111
+ learning,
112
+ worktrees: found.worktrees.map((w) => ({
113
+ name: w.name,
114
+ path: w.path,
115
+ owner: w.ownerName,
116
+ ownerMatchesDirName: w.ownerMatches,
117
+ })),
118
+ directories: found.directories.map((d) => basename(d)),
119
+ };
120
+
121
+ if (present(payload, () => render(rows, summary, found, learning), flags) === 'json') {
121
122
  return rows.some((r) => r.problems.length > 0) ? EXIT.FAIL : EXIT.OK;
122
123
  }
123
124
 
124
- emit(render(rows, summary, found, learning), { maxBytes: flags.maxBytes ?? 0 });
125
125
  if (summary.shape === 'repo') {
126
126
  note('note one repository compared; pass --root at a workspace directory to compare its siblings');
127
127
  } else if (target.source === 'none') {
@@ -5,7 +5,7 @@
5
5
  // `$fpath`. A report line above it would be a report line in the user's shell
6
6
  // config.
7
7
 
8
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
+ import { EXIT, fail, present } from '../output/index.mjs';
9
9
  import { SHELLS, renderCompletions } from '../completions.mjs';
10
10
 
11
11
  export async function completionsCommand(args, flags, commands) {
@@ -18,10 +18,6 @@ export async function completionsCommand(args, flags, commands) {
18
18
  }
19
19
 
20
20
  const script = renderCompletions(shell, commands);
21
- if (isJson(flags)) {
22
- emitJson({ ok: true, command: 'completions', shell, script });
23
- return EXIT.OK;
24
- }
25
- emit(script, { maxBytes: flags.maxBytes ?? 0 });
21
+ present({ ok: true, command: 'completions', shell, script }, () => script, flags);
26
22
  return EXIT.OK;
27
23
  }
@@ -10,9 +10,10 @@ import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
10
  import { commitJournal } from '../journal.mjs';
11
11
  import { diffField, emitDiffs } from '../diff.mjs';
12
12
  import { parsePathList } from '../argv.mjs';
13
+ import { commandRoot } from '../repo-root.mjs';
13
14
 
14
15
  export async function decideCommand(args, flags) {
15
- const root = flags.root ?? process.cwd();
16
+ const root = commandRoot(flags);
16
17
 
17
18
  if (flags.check === true) {
18
19
  // Q55: one resolution — the manifest key, then the detected directory —
@@ -31,7 +32,7 @@ export async function decideCommand(args, flags) {
31
32
  }
32
33
 
33
34
  if (res.findings.length === 0) {
34
- emit('decisions ok', { maxBytes: flags.maxBytes ?? 0 });
35
+ emit('decisions ok');
35
36
  return EXIT.OK;
36
37
  }
37
38
 
@@ -42,7 +43,6 @@ export async function decideCommand(args, flags) {
42
43
  ...(f.remedy ? [` remedy: ${f.remedy}`] : []),
43
44
  ])
44
45
  .join('\n'),
45
- { maxBytes: flags.maxBytes ?? 0 },
46
46
  );
47
47
  return EXIT.FAIL;
48
48
  }
@@ -123,7 +123,7 @@ export async function decideCommand(args, flags) {
123
123
  : `would write ${res.relPath}; apply with: ${CLI} decide ${JSON.stringify(res.title)} --yes`,
124
124
  ];
125
125
 
126
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
126
+ emit(lines.join('\n'));
127
127
  // After the report: the report says what, the diff shows it (R50).
128
128
  emitDiffs(diffs, flags);
129
129
  return EXIT.OK;
@@ -16,7 +16,7 @@ export async function distillCommand(args, flags) {
16
16
  if (typeof result === 'number') return result;
17
17
 
18
18
  if (isJson(flags)) {
19
- emitJson(result, { maxBytes: flags.maxBytes ?? 0 });
19
+ emitJson(result);
20
20
  return EXIT.OK;
21
21
  }
22
22
 
@@ -27,10 +27,11 @@ export async function distillCommand(args, flags) {
27
27
  `engine ${result.engine}`,
28
28
  `budget ${result.budget} bytes`,
29
29
  `citations ${result.citations}`,
30
- ...(result.verified === true ? [] : result.faults.map((f) => `unverified ${f}`)),
30
+ // A cached proposal predates this field; `?? []` keeps the report from crashing on
31
+ // the file the command wrote one release ago (review 0003 Q52).
32
+ ...(result.verified === true ? [] : (result.faults ?? []).map((f) => `unverified ${f}`)),
31
33
  `proposal ${result.proposal.path} (${result.proposal.state}, ${result.proposal.bytes} bytes)`,
32
34
  ].join('\n'),
33
- { maxBytes: flags.maxBytes ?? 0 },
34
35
  );
35
36
  return EXIT.OK;
36
37
  }
@@ -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
+ }
@@ -22,18 +22,20 @@
22
22
  // `--yes` previews: it names what it would apply and lists the rest.
23
23
 
24
24
  import { join } from 'node:path';
25
+ import { repoContext } from '../context.mjs';
25
26
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
26
27
  import { CLI, MANIFEST_FILE } from '../name.mjs';
27
28
  import { fingerprint } from '../resolve/fingerprint.mjs';
28
29
  import { resolveWorkspace } from '../workspace.mjs';
29
30
  import { sharedWarning } from './shared.mjs';
30
31
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
31
- import { manifestPath, readManifest, writeManifest } from '../projection/manifest.mjs';
32
+ import { manifestPath, writeManifest } from '../projection/manifest.mjs';
32
33
  import { planManifestRepair } from './sync.mjs';
33
34
  import { provenanceDigest } from '../projection/project.mjs';
34
35
  import { applyHooksInstall, plannedDiffs, planHooksInstall } from './hooks.mjs';
35
36
  import { fileFor } from '../hooks/shim.mjs';
36
37
  import { commitJournal, ignoreJournal, journalHygiene, journalIgnoreNext } from '../journal.mjs';
38
+ import { commandRoot } from '../repo-root.mjs';
37
39
 
38
40
  /**
39
41
  * One config-health finding: what is wrong, how to fix it, and whether
@@ -47,7 +49,7 @@ import { commitJournal, ignoreJournal, journalHygiene, journalIgnoreNext } from
47
49
  // finding creation, before any fix is applied, so the before side is true in
48
50
  // preview and in apply alike. Only safe findings carry pairs: an unsafe finding
49
51
  // is never applied, so there is no write to preview.
50
- async function collectFindings(root, { result, ws, shared, withDiffs = false }) {
52
+ async function collectFindings(root, { result, ws, shared, withDiffs = false, ctx }) {
51
53
  const findings = [];
52
54
  if (result.error) {
53
55
  // Nothing else can be asked: classification failed, so every check built on
@@ -63,7 +65,7 @@ async function collectFindings(root, { result, ws, shared, withDiffs = false })
63
65
  return findings;
64
66
  }
65
67
 
66
- const { manifest, exists, error: manifestError } = await readManifest(root);
68
+ const { manifest, exists, error: manifestError } = await ctx.manifest();
67
69
  if (manifestError !== null) {
68
70
  // An unreadable manifest is a hand-edit, not a repair: re-rendering it
69
71
  // would destroy whatever the other tool was saying.
@@ -232,7 +234,10 @@ function findingLines(findings, { fixMode, applying }) {
232
234
  //
233
235
  // See the module header: classification first, because everything else is a guess without it.
234
236
  export async function diagnose(root, args = {}) {
235
- const result = await fingerprint(root);
237
+ // One context for the whole diagnosis, so a caller that already holds one (a UI, a
238
+ // harness, an MCP request) does not pay for a second fingerprint (review 0003 Q67).
239
+ const ctx = args.ctx ?? repoContext(root);
240
+ const result = await ctx.fingerprint();
236
241
  // Which source answered the workspace question, and where it looked. A tool
237
242
  // that silently fell back to the wrong directory would make every downstream
238
243
  // answer confidently wrong, so the answer is reported rather than inferred
@@ -246,7 +251,7 @@ export async function diagnose(root, args = {}) {
246
251
  const shared = await sharedWarning(root, result.manifest ?? null, args);
247
252
 
248
253
  const withDiffs = args.fix === true && args.diff === true;
249
- const findings = await collectFindings(root, { result, ws, shared, withDiffs });
254
+ const findings = await collectFindings(root, { result, ws, shared, withDiffs, ctx });
250
255
  // Under `--json` the document is the whole report, so it has to carry both
251
256
  // failures the text path reports AND the workspace answer the text prints.
252
257
  const failure = result.error
@@ -276,7 +281,7 @@ export function serialisedFindings(findings) {
276
281
  }
277
282
 
278
283
  export async function runDoctor(positional, flags) {
279
- const root = flags.root ?? positional[0] ?? process.cwd();
284
+ const root = commandRoot(flags, positional);
280
285
  const { result, ws, shared, findings, failure, code: diagnosisCode } = await diagnose(root, flags);
281
286
 
282
287
  const fixMode = flags.fix === true;
@@ -344,7 +349,6 @@ export async function runDoctor(positional, flags) {
344
349
  ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
345
350
  ...findingLines(findings, { fixMode, applying: mode.apply }),
346
351
  ].join('\n'),
347
- { maxBytes: flags.maxBytes ?? 0 },
348
352
  );
349
353
  // After the report: the report says what, the diff shows it (R50). Only the
350
354
  // fix plan is diffed, so without --fix there is nothing to show.
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { readFile } from 'node:fs/promises';
8
8
 
9
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
10
10
  import { readManifest } from '../projection/manifest.mjs';
11
11
  import { CLI, MANIFEST_FILE } from '../name.mjs';
12
12
  import {
@@ -17,9 +17,10 @@ import {
17
17
  readLedger,
18
18
  treeId,
19
19
  } from '../ledger/index.mjs';
20
+ import { commandRoot } from '../repo-root.mjs';
20
21
 
21
22
  export async function evidenceCommand(args, flags) {
22
- const root = flags.root ?? process.cwd();
23
+ const root = commandRoot(flags);
23
24
  if (args[0] === 'check') return checkClaim(root, args[1], flags);
24
25
  if (args[0] !== undefined) {
25
26
  return fail(`unknown evidence subcommand ${args[0]}`, EXIT.USAGE, `run: ${CLI} evidence`);
@@ -42,7 +43,7 @@ async function showEvidence(root, flags) {
42
43
  const mine = entries.filter((e) => e.tree === tree);
43
44
  const payload = { ok: errors.length === 0, command: 'evidence', root, tree, entries: mine, errors };
44
45
  if (isJson(flags)) {
45
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
46
+ emitJson(payload);
46
47
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
47
48
  }
48
49
  const lines = [`tree ${tree}`];
@@ -51,7 +52,7 @@ async function showEvidence(root, flags) {
51
52
  lines.push(`${e.exit === 0 ? 'pass' : 'fail'} ${e.cmd} exit ${e.exit} ${e.summary}`);
52
53
  }
53
54
  for (const error of errors) lines.push(`error ${error}`);
54
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
55
+ emit(lines.join('\n'));
55
56
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
56
57
  }
57
58
 
@@ -97,10 +98,6 @@ async function checkClaim(root, file, flags) {
97
98
  }
98
99
  const detail = 'the message claims a run this tree does not have';
99
100
  const remedy = `run the command, then commit, or take the claim out of the message`;
100
- if (isJson(flags)) {
101
- emitJson({ ...payload, detail, remedy }, { pretty: flags.pretty === true });
102
- return mode === 'block' ? EXIT.FAIL : EXIT.OK;
103
- }
104
- emit(`${mode === 'block' ? 'BLOCKED' : 'WARNING'} ${detail}\n remedy: ${remedy}`, { maxBytes: flags.maxBytes ?? 0 });
101
+ present({ ...payload, detail, remedy }, () => `${mode === 'block' ? 'BLOCKED' : 'WARNING'} ${detail}\n remedy: ${remedy}`, flags);
105
102
  return mode === 'block' ? EXIT.FAIL : EXIT.OK;
106
103
  }
@@ -46,7 +46,6 @@ export async function explainCommand(positional, flags) {
46
46
  }
47
47
  emit(
48
48
  [`code ${code}`, '', row.what, '', `Why: ${row.why}`, '', `Fix: ${row.fix}`, '', `Example: ${row.example}`].join('\n'),
49
- { maxBytes: flags.maxBytes ?? 0 },
50
49
  );
51
50
  return EXIT.OK;
52
51
  }
@@ -7,22 +7,29 @@
7
7
 
8
8
  import { execFile } from 'node:child_process';
9
9
  import { existsSync } from 'node:fs';
10
- import { mkdir, writeFile } from 'node:fs/promises';
10
+ import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { promisify } from 'node:util';
13
13
 
14
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
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';
22
+ import { commandRoot } from '../repo-root.mjs';
23
+ import { gitPaths } from '../sys/git.mjs';
24
+ import { cmp } from '../util/sort.mjs';
21
25
 
22
26
  const exec = promisify(execFile);
23
27
 
28
+ /** How many raw gate logs the state directory keeps. */
29
+ const RAW_LOG_KEEP = 5;
30
+
24
31
  export async function gateCommand(args, flags) {
25
- const root = flags.root ?? process.cwd();
32
+ const root = commandRoot(flags);
26
33
  const listed = await tracked(root);
27
34
  if (listed.error) return listed.error;
28
35
  let changed = args.filter(Boolean);
@@ -38,10 +45,9 @@ export async function gateCommand(args, flags) {
38
45
  if (isJson(flags)) {
39
46
  emitJson(
40
47
  { ok: true, command: 'gate', root, selected: [], unmatched: [], graph: 'resolved', verdict: 'nothing-selected', code: 0 },
41
- { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
42
48
  );
43
49
  } else {
44
- emit('selected 0\nverdict nothing-selected', { maxBytes: flags.maxBytes ?? 0 });
50
+ emit('selected 0\nverdict nothing-selected');
45
51
  }
46
52
  return EXIT.OK;
47
53
  }
@@ -62,7 +68,7 @@ export async function gateCommand(args, flags) {
62
68
  const files = new Set(listed.files);
63
69
  for (const file of changed) files.add(file);
64
70
  const edges = await readImportEdges(root, files);
65
- const selected = selectTests(changed, edges);
71
+ const selected = coverage({ imports: edges }, changed);
66
72
  // Q40: the selection explains itself. `unmatched` names explicit paths the
67
73
  // tracked tree does not carry (untracked but present, so the run proceeds);
68
74
  // `graph` is `unresolved` when a language has edges but none of them reaches
@@ -74,13 +80,13 @@ export async function gateCommand(args, flags) {
74
80
  if (selected.length === 0) {
75
81
  const payload = { ok: true, command: 'gate', root, selected, unmatched, graph, verdict: 'nothing-selected', code: 0 };
76
82
  if (isJson(flags)) {
77
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
83
+ emitJson(payload);
78
84
  return EXIT.OK;
79
85
  }
80
86
  const lines = ['selected 0', 'verdict nothing-selected'];
81
87
  if (unmatched.length > 0) lines.push(`unmatched ${unmatched.join(', ')}`);
82
88
  if (graph === 'unresolved') lines.push('graph unresolved');
83
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
89
+ emit(lines.join('\n'));
84
90
  return EXIT.OK;
85
91
  }
86
92
  return runSelected(root, flags, selected, { unmatched, graph });
@@ -170,11 +176,7 @@ async function runSelected(root, flags, selected, { unmatched = [], graph = 'res
170
176
  raw,
171
177
  runs,
172
178
  };
173
- if (isJson(flags)) {
174
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
175
- return code === 0 ? EXIT.OK : EXIT.FAIL;
176
- }
177
- emit(text.filter((line) => line !== undefined).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
179
+ present(payload, () => text.filter((line) => line !== undefined).join('\n'), flags);
178
180
  return code === 0 ? EXIT.OK : EXIT.FAIL;
179
181
  }
180
182
 
@@ -185,6 +187,11 @@ async function writeRaw(root, body) {
185
187
  const dir = join(root, dirname(rel));
186
188
  await mkdir(dir, { recursive: true });
187
189
  await writeFile(join(root, rel), body);
190
+ // Keep the newest few: raw gate output is machine-local state (Q19), and a file per
191
+ // run with no bound is a directory that grows on every command (Q83).
192
+ const kept = await readdir(dir).catch(() => []);
193
+ const logs = kept.filter((name) => /^gate-\d+\.log$/.test(name)).sort().reverse();
194
+ for (const name of logs.slice(RAW_LOG_KEEP)) await unlink(join(dir, name)).catch(() => {});
188
195
  return rel;
189
196
  } catch {
190
197
  return '';
@@ -195,8 +202,7 @@ async function tracked(root) {
195
202
  try {
196
203
  // `-z`: without it git C-quotes a non-ASCII path (`src/\303\274n...`) and
197
204
  // the reader's real filename never matches it (review 0003 Q39).
198
- const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
199
- return { files: stdout.split('\0').filter(Boolean) };
205
+ return { files: await gitPaths(root, ['ls-files']) };
200
206
  } catch (error) {
201
207
  return {
202
208
  error: fail(
@@ -210,9 +216,9 @@ async function tracked(root) {
210
216
 
211
217
  async function changedFiles(root) {
212
218
  try {
213
- const { stdout } = await exec('git', ['diff', '--name-only', '-z', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
214
- const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 8 << 20 });
215
- 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() };
216
222
  } catch (error) {
217
223
  return {
218
224
  error: fail(
@@ -224,8 +230,3 @@ async function changedFiles(root) {
224
230
  }
225
231
  }
226
232
 
227
- function cmp(a, b) {
228
- if (a < b) return -1;
229
- if (a > b) return 1;
230
- return 0;
231
- }
@@ -71,7 +71,6 @@ export async function lintCommand(positional, flags) {
71
71
  errors,
72
72
  warnings,
73
73
  },
74
- { maxBytes: flags.maxBytes ?? 0 },
75
74
  );
76
75
  return finalOk ? EXIT.OK : EXIT.REGISTRY;
77
76
  }
@@ -109,11 +108,11 @@ export async function lintCommand(positional, flags) {
109
108
  if (errors.length > 0) {
110
109
  lines.push('', `FAIL ${errors.length}`);
111
110
  for (const error of errors) lines.push(` ${error}`);
112
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
111
+ emit(lines.join('\n'));
113
112
  return EXIT.REGISTRY;
114
113
  }
115
114
 
116
115
  lines.push('', verified ? 'OK no findings' : 'OK structural checks only');
117
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
116
+ emit(lines.join('\n'));
118
117
  return EXIT.OK;
119
118
  }
@@ -27,6 +27,7 @@ import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
27
27
  import { SURFACES, CANDIDATE_DIRS } from '../projection/surfaces.mjs';
28
28
  import { LOADERS, LOADER_IDS, caps, loader } from '../projection/loaders.mjs';
29
29
  import { viewAll } from '../measure/loaderView.mjs';
30
+ import { commandRoot } from '../repo-root.mjs';
30
31
 
31
32
  /**
32
33
  * `simt harness detect`. The subcommand is validated by dispatch, which rejects a
@@ -39,7 +40,7 @@ import { viewAll } from '../measure/loaderView.mjs';
39
40
  * @returns {Promise<number>} process exit code
40
41
  */
41
42
  export async function harnessCommand(positional, flags) {
42
- const root = flags.root ?? process.cwd();
43
+ const root = commandRoot(flags);
43
44
 
44
45
  // MCP evidence is project-scope by construction: the loader table carries repo-root
45
46
  // paths (.mcp.json, .codex/config.toml, …), and the same path means different scopes
@@ -99,13 +100,13 @@ async function scan(root, { mcp = false } = {}) {
99
100
  const rows = [];
100
101
  for (const s of SURFACES) {
101
102
  for (const dir of s.configDirs ?? []) {
102
- if (await present(root, dir)) {
103
+ if (await isDirectory(root, dir)) {
103
104
  rows.push(row(dir, s.label, { id: s.id, verified: s.verified ?? null, readsAgentsMd: s.readsAgentsMd, rule: s.rule }));
104
105
  }
105
106
  }
106
107
  }
107
108
  for (const c of CANDIDATE_DIRS) {
108
- if (await present(root, c.dir)) {
109
+ if (await isDirectory(root, c.dir)) {
109
110
  rows.push(row(c.dir, c.harness, { readsAgentsMd: c.readsAgentsMd ?? null, readsAgentsMdNote: c.readsAgentsMdNote ?? null, verified: c.verified ?? null }));
110
111
  }
111
112
  }
@@ -114,7 +115,7 @@ async function scan(root, { mcp = false } = {}) {
114
115
  return { root, rows };
115
116
  }
116
117
 
117
- async function present(root, dir) {
118
+ async function isDirectory(root, dir) {
118
119
  try {
119
120
  return (await stat(join(root, dir))).isDirectory();
120
121
  } catch {
@@ -256,7 +257,7 @@ function block(scan, pad) {
256
257
  * do" in a repository where the answer is not about bytes.
257
258
  */
258
259
  export async function harnessLoadersCommand(positional, flags) {
259
- const root = flags.root ?? process.cwd();
260
+ const root = commandRoot(flags);
260
261
  const wanted = typeof flags.reader === 'string' ? flags.reader : null;
261
262
  const rows = wanted === null ? LOADERS : [loader(wanted)].filter(Boolean);
262
263