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
@@ -4,9 +4,49 @@
4
4
  // Same data, different rendering. Nothing here emits ANSI, prompts, spinners or
5
5
  // boxes, because a prompt is a hang for an agent and a spinner is noise in a log.
6
6
  //
7
- // See docs/plans/0002-simt-dev-cli.md section 6.
7
+ // See docs/research/0002-simt-dev-cli.md section 6.
8
+
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
10
 
9
11
  import { CLI } from '../name.mjs';
12
+
13
+ /**
14
+ * One call's presentation state (review 0003 Q63, Q65).
15
+ *
16
+ * This was seven module-level `let`s - the JSON failure mode, the indent default, the byte
17
+ * ceiling, the accumulated notices, the canonical command, the capture, the sink - and a
18
+ * process could hold only one of each. `dispatch` and the tests both run more than one
19
+ * command in a process, and an MCP server runs them concurrently: the second run's ceiling,
20
+ * notices and command path landed in the first run's document, which is a document
21
+ * describing an invocation that did not produce it.
22
+ *
23
+ * Async-local rather than threaded through a parameter: `emit` is called by seventy command
24
+ * modules that have no context to pass, and changing every signature is the rewrite that
25
+ * never lands. A store is per call and inherited by everything that call awaits.
26
+ */
27
+ const stores = new AsyncLocalStorage();
28
+
29
+ /** What a run that never opened a store gets: the process defaults before any flag. */
30
+ const processState = () => ({
31
+ jsonFailure: false,
32
+ pretty: process.stdout.isTTY === true,
33
+ maxBytes: 0,
34
+ warnings: [],
35
+ command: null,
36
+ captured: null,
37
+ sink: null,
38
+ });
39
+
40
+ const fallback = processState();
41
+
42
+ function state() {
43
+ return stores.getStore() ?? fallback;
44
+ }
45
+
46
+ /** Give `body` its own presentation state, so two calls in one process cannot collide. */
47
+ export function withOutputState(body) {
48
+ return stores.run(processState(), body);
49
+ }
10
50
  /** Stable machine codes. A non-zero exit always carries one of these. */
11
51
  export const EXIT = {
12
52
  OK: 0,
@@ -40,10 +80,8 @@ const REMEDY = {
40
80
  * The default answers "plain text" because that is what every run before the flags
41
81
  * were parsed is, and those runs happen before anybody could have asked.
42
82
  */
43
- let jsonFailure = false;
44
-
45
83
  export function jsonFailMode(on = false) {
46
- jsonFailure = on;
84
+ state().jsonFailure = on;
47
85
  }
48
86
 
49
87
  /**
@@ -56,7 +94,12 @@ export function fail(message, code = EXIT.FAIL, remedy) {
56
94
  // a reader in a consumer repo got a remedy naming a file that does not exist
57
95
  // where they are. Every remedy here is something the reader can run.
58
96
  const resolved = remedy ?? REMEDY[code] ?? `run: ${CLI} --help`;
59
- if (jsonFailure) {
97
+ // A library caller asked for a result, not a line on a terminal it does not own.
98
+ if (state().sink !== null) {
99
+ state().sink.error = { message, remedy: resolved, exitCode: code };
100
+ return code;
101
+ }
102
+ if (state().jsonFailure) {
60
103
  emitJson({ ok: false, error: message, remedy: resolved, exitCode: code });
61
104
  return code;
62
105
  }
@@ -92,8 +135,32 @@ export function writeMode(flags) {
92
135
  * Truncation is always announced with an explicit line, never a silent ellipsis:
93
136
  * an agent that cannot tell a truncated answer from a complete one will guess.
94
137
  */
95
- export function emit(text, { maxBytes = 0, stream = process.stdout } = {}) {
138
+ /**
139
+ * Answer one command's result on the surface the caller asked for.
140
+ *
141
+ * Every command that has both a document and a text report was choosing between them by
142
+ * hand, in the same five lines, in more than a dozen files - and a command that chose
143
+ * slightly differently printed a report where a caller had asked for JSON, which is how a
144
+ * script gets prose (review 0003 Q63).
145
+ *
146
+ * The renderer is a thunk because the surface is decided first: a command asked for JSON
147
+ * must not pay to render text nobody reads, and a caller cannot forget the branch.
148
+ */
149
+ export function present(payload, renderText, flags) {
150
+ if (isJson(flags)) {
151
+ emitJson(payload);
152
+ return 'json';
153
+ }
154
+ emit(renderText());
155
+ return 'text';
156
+ }
157
+
158
+ export function emit(text, { maxBytes = state().maxBytes, stream = process.stdout } = {}) {
96
159
  const body = text.endsWith('\n') ? text : `${text}\n`;
160
+ if (state().sink !== null) {
161
+ state().sink.text.push(fitText(body, maxBytes));
162
+ return true;
163
+ }
97
164
  const bytes = Buffer.byteLength(body);
98
165
  if (maxBytes > 0 && bytes > maxBytes) {
99
166
  const slice = Buffer.from(body).subarray(0, maxBytes).toString('utf8');
@@ -129,10 +196,19 @@ export const SCHEMA_VERSION = 1;
129
196
  * important. The default answers "is a person reading", which is the right answer
130
197
  * before anybody sets it.
131
198
  */
132
- let prettyDefault = process.stdout.isTTY === true;
199
+ // The byte ceiling is one flag with one reader: the envelope. Commands used to pass
200
+ // `flags.maxBytes ?? 0` at seventy-eight call sites, which is seventy-eight chances to pass
201
+ // a different number, or to forget and answer past the ceiling the caller set (review 0003
202
+ // Q65). A ceiling of zero or a value that is not a number is no ceiling, the same way the
203
+ // flag itself is read.
204
+ /** Set the byte ceiling every emit and every document inherits, from `--max-bytes`. */
205
+ export function maxBytesLimit(bytes) {
206
+ const value = Number(bytes);
207
+ state().maxBytes = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
208
+ }
133
209
 
134
210
  export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
135
- prettyDefault = pretty;
211
+ state().pretty = pretty;
136
212
  }
137
213
 
138
214
  /**
@@ -203,8 +279,127 @@ function fitJsonArrays(payload, maxBytes, pretty) {
203
279
  return null;
204
280
  }
205
281
 
206
- export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
207
- const payload = withSchemaVersion(value);
282
+ /**
283
+ * Warnings a run accumulated, carried into the JSON document it prints.
284
+ *
285
+ * The same reason stderr is separate from stdout: a caller reading a document cannot
286
+ * see a note printed beside it. A command that ignored half of what it was asked still
287
+ * has to succeed or fail the same way, so the note belongs IN the result, and the text
288
+ * mode keeps the stderr line it already had (review 0003 Q62).
289
+ */
290
+ /**
291
+ * The output sink for a library call, or null for a process (review 0003 Q63).
292
+ *
293
+ * `run(command, input)` answers with the text, the documents, the notes and the failure a
294
+ * command produced instead of writing them. The alternative - threading a `result` return
295
+ * through seventy command modules - is the rewrite that never finishes, and the printing
296
+ * commands have no return value to thread: they `return EXIT.OK` after calling `emit`.
297
+ *
298
+ * A sink is per call and never nested. The dispatcher keeps its own JSON capture below and
299
+ * is not this: a sink answers "what did the command produce", the capture answers "hold the
300
+ * document until the exit code exists".
301
+ */
302
+ /** Collect a command's output instead of writing it, and answer with what it produced. */
303
+ export async function withSink(body) {
304
+ if (state().sink !== null) throw new Error('an output sink is already open');
305
+ const collecting = { text: [], documents: [], notes: [], error: null };
306
+ state().sink = collecting;
307
+ try {
308
+ const code = await body();
309
+ return {
310
+ code: code ?? EXIT.OK,
311
+ text: collecting.text.join(''),
312
+ documents: collecting.documents,
313
+ notes: collecting.notes,
314
+ error: collecting.error,
315
+ };
316
+ } finally {
317
+ state().sink = null;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The same cut `emit` makes on a stream, applied to a string: the ceiling is a promise
323
+ * whichever surface reads it, and a library caller that got bytes past the limit it asked
324
+ * for would be paying for what the flag exists to refuse.
325
+ */
326
+ function fitText(body, maxBytes) {
327
+ const bytes = Buffer.byteLength(body);
328
+ if (maxBytes <= 0 || bytes <= maxBytes) return body;
329
+ const slice = Buffer.from(body).subarray(0, maxBytes).toString('utf8');
330
+ const cut = slice.lastIndexOf('\n');
331
+ return cut > 0
332
+ ? `${slice.slice(0, cut)}\nTRUNCATED ${bytes - maxBytes} bytes omitted, rerun with --max-bytes 0\n`
333
+ : `${slice}TRUNCATED ${bytes - maxBytes} bytes omitted, rerun with --max-bytes 0\n`;
334
+ }
335
+
336
+ // The path the dispatcher resolved. A command should not restate it: the ones that did
337
+ // restated it wrongly (`plugin` printed `plugin claude`, `sync` printed `sync --check`),
338
+ // and a document whose `command` disagrees with the argv that produced it is worse than
339
+ // one with no `command` at all (review 0003 Q65).
340
+ /** Set the command path every JSON document will carry, or clear it with null. */
341
+ export function jsonCommand(path) {
342
+ state().command = path ?? null;
343
+ }
344
+
345
+ // A document cannot name the exit code before the command has returned one. The
346
+ // dispatcher captures what a command emits, then writes it once with the code attached;
347
+ // writing first is how `ok: false` shipped alongside exit 0 (review 0003 Q65).
348
+ /** Collect documents instead of writing them, so the dispatcher can add the exit code. */
349
+ export function jsonCaptureStart() {
350
+ state().captured = [];
351
+ }
352
+
353
+ /** The captured documents and a reset, or an empty list when nothing was captured. */
354
+ export function jsonCaptureTake() {
355
+ const docs = state().captured;
356
+ state().captured = null;
357
+ return docs;
358
+ }
359
+
360
+ /** Record a warning for the document this run prints. */
361
+ export function warn(message) {
362
+ state().warnings.push(message);
363
+ }
364
+
365
+ /**
366
+ * Drop what a previous run in this process recorded.
367
+ *
368
+ * The collector is per process and a process can dispatch more than once - the MCP
369
+ * server and the tests both do. Without this, one command's notice appeared in the
370
+ * next command's document, which parity between the two surfaces caught (review 0003
371
+ * Q62).
372
+ */
373
+ export function clearWarnings() {
374
+ state().warnings = [];
375
+ }
376
+
377
+ /** The warnings this run has recorded, in order. */
378
+ export function warnings() {
379
+ return state().warnings;
380
+ }
381
+
382
+ export function emitJson(value, { stream = process.stdout, pretty = state().pretty, maxBytes = state().maxBytes } = {}) {
383
+ // `notices`, not `warnings`: `status` already reports a warning COUNT under that key,
384
+ // and a document cannot mean two things by one name. The collision is named here
385
+ // rather than bent into either shape (review 0003 Q62).
386
+ const carried =
387
+ state().warnings.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
388
+ ? { ...value, notices: state().warnings }
389
+ : value;
390
+ const stamped =
391
+ state().command !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
392
+ ? { ...carried, command: state().command }
393
+ : carried;
394
+ if (state().sink !== null) {
395
+ state().sink.documents.push(withSchemaVersion(stamped));
396
+ return true;
397
+ }
398
+ if (state().captured !== null) {
399
+ state().captured.push({ value: stamped });
400
+ return true;
401
+ }
402
+ const payload = withSchemaVersion(stamped);
208
403
  const text = `${stringifyJson(payload, pretty)}\n`;
209
404
  // JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
210
405
  // true. Arrays shrink with a `truncated` record naming what lost how much, and a
@@ -239,5 +434,9 @@ export function emitJson(value, { stream = process.stdout, pretty = prettyDefaul
239
434
  * diagnostics that accompany a real result, never for the result itself.
240
435
  */
241
436
  export function note(message, { stream = process.stderr } = {}) {
437
+ if (state().sink !== null) {
438
+ state().sink.notes.push(message);
439
+ return;
440
+ }
242
441
  stream.write(`${message}\n`);
243
442
  }
@@ -15,7 +15,8 @@
15
15
 
16
16
  import { execFile } from 'node:child_process';
17
17
  import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
18
- import { commitJournal, journaledWriteFile } from '../journal.mjs';
18
+ import { applyPlan, change } from '../write/plan.mjs';
19
+ import { readBefore } from '../diff.mjs';
19
20
  import { createHash } from 'node:crypto';
20
21
  import { basename, dirname, join } from 'node:path';
21
22
  import { promisify } from 'node:util';
@@ -23,6 +24,7 @@ import { promisify } from 'node:util';
23
24
  import { PACK_DIR, packDigest, packNameProblem } from '../packs.mjs';
24
25
  import { readManifest } from '../projection/manifest.mjs';
25
26
  import { CLI, MANIFEST_FILE } from '../name.mjs';
27
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
26
28
 
27
29
  const exec = promisify(execFile);
28
30
 
@@ -311,10 +313,10 @@ export async function lintPack(dir, { requireMeasured = false, declared = null }
311
313
  fail(rel, 'pack-rule-unreadable', 'the file cannot be read', 'restore it or remove it from the pack');
312
314
  continue;
313
315
  }
314
- const frontmatter = /^---\n([\s\S]*?)\n---\n?/.exec(text);
316
+ const { front } = parseFrontmatter(text);
315
317
  // Multiline: the id sits on its own line inside the frontmatter block, and
316
318
  // without `m` the anchors hold the whole block instead of the line.
317
- const id = frontmatter === null ? null : /^simt-id:\s*(.+?)\s*$/m.exec(frontmatter[1])?.[1] ?? null;
319
+ const id = front === null ? null : /^simt-id:\s*(.+?)\s*$/m.exec(front)?.[1] ?? null;
318
320
  if (id === null) {
319
321
  fail(rel, 'pack-rule-without-id', 'the rule has no `simt-id`: without one nothing selects it and nothing explains it', 'give the rule a `simt-id` in its frontmatter');
320
322
  }
@@ -511,6 +513,23 @@ export async function publishPack(dir, { apply = false } = {}) {
511
513
  return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
512
514
  }
513
515
 
516
+ // A preview is a preview. The registry read below is the one network call in this
517
+ // command, and running it to print a plan made `pack publish` — a preview by default
518
+ // — a network operation that failed offline without `--yes` (review 0003 Q58).
519
+ if (!apply) {
520
+ return {
521
+ pack: linted.pack,
522
+ ok: true,
523
+ published: false,
524
+ skipped: false,
525
+ planned: [
526
+ `npm view ${pkg}@${pack.version} (check whether it is already live)`,
527
+ `npm publish (in ${dir})`,
528
+ ],
529
+ findings,
530
+ };
531
+ }
532
+
514
533
  // Idempotent, the way the release twin is: a version that is already live is
515
534
  // skipped rather than republished, so a re-run after an interruption continues
516
535
  // where it stopped.
@@ -534,9 +553,6 @@ export async function publishPack(dir, { apply = false } = {}) {
534
553
  }
535
554
 
536
555
  const planned = [`npm publish (in ${dir})`];
537
- if (!apply) {
538
- return { pack: linted.pack, ok: true, published: false, skipped: false, planned, findings };
539
- }
540
556
  try {
541
557
  await exec('npm', ['publish'], { cwd: dir });
542
558
  } catch (error) {
@@ -563,10 +579,14 @@ export async function writeScaffold(dir, files, root = dirname(dir)) {
563
579
  if (error?.message?.startsWith('refusing to scaffold')) throw error;
564
580
  }
565
581
  const journal = [];
566
- for (const file of files) {
567
- const abs = join(dir, file.path);
568
- await mkdir(dirname(abs), { recursive: true });
569
- await journaledWriteFile(root, journal, abs, file.text);
570
- }
571
- await commitJournal(root, 'pack new', journal);
582
+ await applyPlan(
583
+ root,
584
+ await Promise.all(
585
+ files.map(async (file) => {
586
+ const abs = join(dir, file.path);
587
+ return change({ path: abs, before: await readBefore(abs), after: file.text, owner: 'pack new', reason: 'the pack is being created' });
588
+ }),
589
+ ),
590
+ { mode: { apply: true }, label: 'pack new' },
591
+ );
572
592
  }
package/src/packs.mjs CHANGED
@@ -72,8 +72,26 @@ export async function packDigest(dir, { exclude = [] } = {}) {
72
72
  }
73
73
  for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
74
74
  const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
75
- if (entry.isDirectory()) await walk(join(d, entry.name), rel);
76
- else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
75
+ if (entry.isDirectory()) {
76
+ await walk(join(d, entry.name), rel);
77
+ } else if (entry.isSymbolicLink()) {
78
+ // A link is not vendored bytes, and the loader follows it: the old walk skipped
79
+ // links while the projection read through them, so a rule reached by a link was
80
+ // projected without being covered by the pin (review 0003 Q58). A link to a
81
+ // file is hashed through its target, because `readFile` below follows it. A link
82
+ // to a directory is refused: recursing would let a pack's digest describe a tree
83
+ // it does not carry.
84
+ const target = await stat(join(d, entry.name)).catch(() => null);
85
+ if (target?.isDirectory()) {
86
+ const error = new Error(
87
+ `${rel} is a symbolic link to a directory; a pack is vendored bytes, and its digest cannot cover a tree it does not carry`,
88
+ );
89
+ error.code = 'PACK_SYMLINK';
90
+ error.path = rel;
91
+ throw error;
92
+ }
93
+ if (!excluded.has(rel)) files.push(rel);
94
+ } else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
77
95
  }
78
96
  };
79
97
  await walk(dir);
@@ -3,7 +3,7 @@
3
3
  // An agent resuming a long plan should read one slice, not the document. Which
4
4
  // directories are read, which columns count, and which words count as done are
5
5
  // the repository's template (src/plan/template.mjs). The tracker template
6
- // delivered with the tool reads docs/plans and docs/reviews and treats `done`
6
+ // delivered with the tool reads docs/plans, docs/research and docs/reviews and treats `done`
7
7
  // as complete.
8
8
  // A dependency may name another document (`review 0002 S41`) or one of its
9
9
  // waves (`review 0002 Wave 0`). Ids that follow a document prefix stay in that
package/src/plugins.mjs CHANGED
@@ -29,10 +29,16 @@ import { fileURLToPath } from 'node:url';
29
29
  import { readBefore } from './diff.mjs';
30
30
  import { hookDocument } from './agenthooks/render.mjs';
31
31
  import { desiredRegion } from './projection/rules.mjs';
32
+ import { harnessPlugins } from './harness/records.mjs';
32
33
  import { PACKAGE_NAME, BIN_REL } from './name.mjs';
33
34
 
35
+ // The plugin layouts, as records (Q71): where each kind's runtime, entry point and hooks
36
+ // document land. A new kind is a new record, not a branch in each helper below.
37
+ const PLUGINS = harnessPlugins();
38
+ const pluginOf = (kind) => PLUGINS.find((r) => r.id === kind);
39
+
34
40
  /** The kinds `simt plugin` builds, in the order the help line names them. */
35
- export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor', 'opencode'];
41
+ export const PLUGIN_KINDS = PLUGINS.map((r) => r.id);
36
42
 
37
43
  /**
38
44
  * The package this code ships in: the runtime a generated package copies. Resolved
@@ -45,37 +51,37 @@ function packageRoot() {
45
51
 
46
52
  /** Where the runtime copy lands inside a package, relative to the package root. */
47
53
  function runtimeDir(kind) {
48
- return kind === 'cursor' ? join('.cursor', 'simt') : '.';
54
+ return pluginOf(kind).runtimeDir;
49
55
  }
50
56
 
51
57
  /** The entry point a package's config names: its own copy of the runtime, not PATH. */
52
58
  function entryPoint(kind) {
53
- const bin = join(runtimeDir(kind), BIN_REL);
59
+ const { runtimeDir: dir, rootVar } = pluginOf(kind);
60
+ const bin = join(dir, BIN_REL);
54
61
  // The vendor's own variables must survive as literal text in the generated config, so
55
62
  // they are escaped here: an unescaped ${...} would interpolate a JS binding that does
56
63
  // not exist and ship the string "undefined" into the package.
57
- if (kind === 'claude') return `node "\${CLAUDE_PLUGIN_ROOT}/${bin}"`;
58
- if (kind === 'gemini') return `node "\${extensionPath}/${bin}"`;
64
+ if (rootVar) return `node "\${${rootVar}}/${bin}"`;
59
65
  // Cursor runs from the repository root, so a relative path is the honest one.
60
66
  return `node ${bin}`;
61
67
  }
62
68
 
63
69
  /** The project root a package's hook resolves against, from the vendor's env var. */
64
70
  function rootFlag(kind) {
65
- if (kind === 'claude') return ` --root "$CLAUDE_PROJECT_DIR"`;
66
- if (kind === 'cursor') return ` --root "$CURSOR_PROJECT_DIR"`;
67
- // Gemini documents no project-dir env var, but its base input carries cwd, which the
68
- // adapter reads; passing a flag the vendor never sets would only expand to "".
69
- return '';
71
+ const { projectVar } = pluginOf(kind);
72
+ if (!projectVar) {
73
+ // Gemini documents no project-dir env var, but its base input carries cwd, which the
74
+ // adapter reads; passing a flag the vendor never sets would only expand to "".
75
+ return '';
76
+ }
77
+ return ` --root "$${projectVar}"`;
70
78
  }
71
79
 
72
- /** Where the hooks document lands inside a package, relative to the package root. */
80
+ /** Where the hooks document lands inside a package, relative to the package root.
81
+ * OpenCode is the one whose document is a file the tool loads whole (its root
82
+ * `server` file) rather than a hooks config to merge. */
73
83
  function hooksPath(kind) {
74
- if (kind === 'cursor') return join('.cursor', 'hooks.json');
75
- // OpenCode loads a local plugin directory's root `server` file as the plugin, so the
76
- // generated document is that file at the root rather than a hooks config to merge.
77
- if (kind === 'opencode') return 'server.js';
78
- return join('hooks', 'hooks.json');
84
+ return pluginOf(kind).hooksPath;
79
85
  }
80
86
 
81
87
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
package/src/prbody.mjs CHANGED
@@ -12,18 +12,15 @@
12
12
  // falls back to the working-tree diff against HEAD: a report that refused
13
13
  // without a base would be useless on a repository with a single branch.
14
14
 
15
- import { execFile } from 'node:child_process';
16
15
  import { readFile, readdir } from 'node:fs/promises';
17
16
  import { join } from 'node:path';
18
- import { promisify } from 'node:util';
19
17
 
20
18
  import { readLedger, treeId } from './ledger/index.mjs';
21
19
  import { readCheckCache } from './status.mjs';
22
20
  import { readManifest } from './projection/manifest.mjs';
23
- import { splitFrontmatter } from './memory/decide.mjs';
21
+ import { parseFrontmatter } from './text/frontmatter.mjs';
24
22
  import { decisionsDir } from './decisions/dir.mjs';
25
-
26
- const exec = promisify(execFile);
23
+ import { git, gitText } from './sys/git.mjs';
27
24
 
28
25
  /**
29
26
  * The ref the body diffs against: the override when given, else the first
@@ -33,7 +30,7 @@ const exec = promisify(execFile);
33
30
  async function resolveBase(root, override = null) {
34
31
  if (override !== null && override !== undefined && String(override) !== '') {
35
32
  try {
36
- await exec('git', ['rev-parse', '--verify', '--quiet', `${override}^{commit}`], { cwd: root });
33
+ await git(root, ['rev-parse', '--verify', '--quiet', `${override}^{commit}`]);
37
34
  return { base: String(override), error: null };
38
35
  } catch {
39
36
  return { base: null, error: `unknown base ${override}` };
@@ -41,7 +38,7 @@ async function resolveBase(root, override = null) {
41
38
  }
42
39
  for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
43
40
  try {
44
- await exec('git', ['merge-base', 'HEAD', candidate], { cwd: root });
41
+ await git(root, ['merge-base', 'HEAD', candidate]);
45
42
  return { base: candidate, error: null };
46
43
  } catch {
47
44
  // Not present or shares no history; the next candidate may.
@@ -66,8 +63,7 @@ async function changedPaths(root, base) {
66
63
  };
67
64
  if (base !== null) {
68
65
  try {
69
- const { stdout } = await exec('git', ['diff', '--name-only', `${base}...HEAD`], {
70
- cwd: root,
66
+ const stdout = await gitText(root, ['diff', '--name-only', `${base}...HEAD`], {
71
67
  maxBuffer: 16 << 20,
72
68
  });
73
69
  add(stdout);
@@ -81,7 +77,7 @@ async function changedPaths(root, base) {
81
77
  ['ls-files', '--others', '--exclude-standard'],
82
78
  ]) {
83
79
  try {
84
- const { stdout } = await exec('git', args, { cwd: root, maxBuffer: 16 << 20 });
80
+ const stdout = await gitText(root, args, { maxBuffer: 16 << 20 });
85
81
  add(stdout);
86
82
  } catch {
87
83
  // Not a repository, or no commit yet: the body reports the absence.
@@ -113,7 +109,7 @@ async function changedDecisions(root, paths) {
113
109
  let status = 'unknown';
114
110
  try {
115
111
  const text = await readFile(join(root, name), 'utf8');
116
- const { body } = splitFrontmatter(text);
112
+ const { body } = parseFrontmatter(text);
117
113
  const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
118
114
  title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim() || id;
119
115
  status = /^Status:\s*(.+)$/im.exec(body)?.[1].trim() ?? 'unknown';