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
@@ -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
  }
@@ -112,8 +155,12 @@ export function present(payload, renderText, flags) {
112
155
  return 'text';
113
156
  }
114
157
 
115
- export function emit(text, { maxBytes = maxBytesDefault, stream = process.stdout } = {}) {
158
+ export function emit(text, { maxBytes = state().maxBytes, stream = process.stdout } = {}) {
116
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
+ }
117
164
  const bytes = Buffer.byteLength(body);
118
165
  if (maxBytes > 0 && bytes > maxBytes) {
119
166
  const slice = Buffer.from(body).subarray(0, maxBytes).toString('utf8');
@@ -149,23 +196,19 @@ export const SCHEMA_VERSION = 1;
149
196
  * important. The default answers "is a person reading", which is the right answer
150
197
  * before anybody sets it.
151
198
  */
152
- let prettyDefault = process.stdout.isTTY === true;
153
-
154
199
  // The byte ceiling is one flag with one reader: the envelope. Commands used to pass
155
200
  // `flags.maxBytes ?? 0` at seventy-eight call sites, which is seventy-eight chances to pass
156
201
  // a different number, or to forget and answer past the ceiling the caller set (review 0003
157
202
  // Q65). A ceiling of zero or a value that is not a number is no ceiling, the same way the
158
203
  // flag itself is read.
159
- let maxBytesDefault = 0;
160
-
161
204
  /** Set the byte ceiling every emit and every document inherits, from `--max-bytes`. */
162
205
  export function maxBytesLimit(bytes) {
163
206
  const value = Number(bytes);
164
- maxBytesDefault = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
207
+ state().maxBytes = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
165
208
  }
166
209
 
167
210
  export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
168
- prettyDefault = pretty;
211
+ state().pretty = pretty;
169
212
  }
170
213
 
171
214
  /**
@@ -244,39 +287,79 @@ function fitJsonArrays(payload, maxBytes, pretty) {
244
287
  * has to succeed or fail the same way, so the note belongs IN the result, and the text
245
288
  * mode keeps the stderr line it already had (review 0003 Q62).
246
289
  */
247
- let accumulated = [];
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
+ }
248
335
 
249
336
  // The path the dispatcher resolved. A command should not restate it: the ones that did
250
337
  // restated it wrongly (`plugin` printed `plugin claude`, `sync` printed `sync --check`),
251
338
  // and a document whose `command` disagrees with the argv that produced it is worse than
252
339
  // one with no `command` at all (review 0003 Q65).
253
- let canonicalCommand = null;
254
-
255
340
  /** Set the command path every JSON document will carry, or clear it with null. */
256
341
  export function jsonCommand(path) {
257
- canonicalCommand = path ?? null;
342
+ state().command = path ?? null;
258
343
  }
259
344
 
260
345
  // A document cannot name the exit code before the command has returned one. The
261
346
  // dispatcher captures what a command emits, then writes it once with the code attached;
262
347
  // writing first is how `ok: false` shipped alongside exit 0 (review 0003 Q65).
263
- let captured = null;
264
-
265
348
  /** Collect documents instead of writing them, so the dispatcher can add the exit code. */
266
349
  export function jsonCaptureStart() {
267
- captured = [];
350
+ state().captured = [];
268
351
  }
269
352
 
270
353
  /** The captured documents and a reset, or an empty list when nothing was captured. */
271
354
  export function jsonCaptureTake() {
272
- const docs = captured;
273
- captured = null;
355
+ const docs = state().captured;
356
+ state().captured = null;
274
357
  return docs;
275
358
  }
276
359
 
277
360
  /** Record a warning for the document this run prints. */
278
361
  export function warn(message) {
279
- accumulated = [...accumulated, message];
362
+ state().warnings.push(message);
280
363
  }
281
364
 
282
365
  /**
@@ -288,28 +371,32 @@ export function warn(message) {
288
371
  * Q62).
289
372
  */
290
373
  export function clearWarnings() {
291
- accumulated = [];
374
+ state().warnings = [];
292
375
  }
293
376
 
294
377
  /** The warnings this run has recorded, in order. */
295
378
  export function warnings() {
296
- return accumulated;
379
+ return state().warnings;
297
380
  }
298
381
 
299
- export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = maxBytesDefault } = {}) {
382
+ export function emitJson(value, { stream = process.stdout, pretty = state().pretty, maxBytes = state().maxBytes } = {}) {
300
383
  // `notices`, not `warnings`: `status` already reports a warning COUNT under that key,
301
384
  // and a document cannot mean two things by one name. The collision is named here
302
385
  // rather than bent into either shape (review 0003 Q62).
303
386
  const carried =
304
- accumulated.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
305
- ? { ...value, notices: accumulated }
387
+ state().warnings.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
388
+ ? { ...value, notices: state().warnings }
306
389
  : value;
307
390
  const stamped =
308
- canonicalCommand !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
309
- ? { ...carried, command: canonicalCommand }
391
+ state().command !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
392
+ ? { ...carried, command: state().command }
310
393
  : carried;
311
- if (captured !== null) {
312
- captured.push({ value: stamped });
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 });
313
400
  return true;
314
401
  }
315
402
  const payload = withSchemaVersion(stamped);
@@ -347,5 +434,9 @@ export function emitJson(value, { stream = process.stdout, pretty = prettyDefaul
347
434
  * diagnostics that accompany a real result, never for the result itself.
348
435
  */
349
436
  export function note(message, { stream = process.stderr } = {}) {
437
+ if (state().sink !== null) {
438
+ state().sink.notes.push(message);
439
+ return;
440
+ }
350
441
  stream.write(`${message}\n`);
351
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
  }
@@ -577,10 +579,14 @@ export async function writeScaffold(dir, files, root = dirname(dir)) {
577
579
  if (error?.message?.startsWith('refusing to scaffold')) throw error;
578
580
  }
579
581
  const journal = [];
580
- for (const file of files) {
581
- const abs = join(dir, file.path);
582
- await mkdir(dirname(abs), { recursive: true });
583
- await journaledWriteFile(root, journal, abs, file.text);
584
- }
585
- 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
+ );
586
592
  }
@@ -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';