simt-dev 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -144,20 +144,90 @@ export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
144
144
  * the only consumer is a person: the compact document saves them nothing and costs them
145
145
  * the diff they were about to read.
146
146
  */
147
+ function withSchemaVersion(value) {
148
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
149
+ ? { schemaVersion: SCHEMA_VERSION, ...value }
150
+ : value;
151
+ }
152
+
153
+ function stringifyJson(value, pretty) {
154
+ return pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
155
+ }
156
+
157
+ /**
158
+ * Shrink a payload to a byte ceiling by emptying its longest arrays first.
159
+ *
160
+ * Byte-cutting JSON produces a document that parses or exits non-zero, never both —
161
+ * `why --json --max-bytes 120` used to exit 0 with half a token on stdout (Q10). An
162
+ * importer owns nothing here: the trimmed arrays are named in `truncated`, so a reader
163
+ * knows exactly which list lost how many items, and the document still parses.
164
+ *
165
+ * Returns the fitted payload, or null when no array-trimming fits (the caller then
166
+ * answers with the `MAX_BYTES` error envelope instead).
167
+ */
168
+ function fitJsonArrays(payload, maxBytes, pretty) {
169
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null;
170
+ const slots = [];
171
+ const walk = (node, path) => {
172
+ if (Array.isArray(node)) {
173
+ if (node.length > 0) slots.push({ node, path, length: node.length });
174
+ node.forEach((item, i) => walk(item, `${path}[${i}]`));
175
+ } else if (node !== null && typeof node === 'object') {
176
+ for (const key of Object.keys(node)) walk(node[key], path === '' ? key : `${path}.${key}`);
177
+ }
178
+ };
179
+ walk(payload, '');
180
+ if (slots.length === 0) return null;
181
+ slots.sort((a, b) => b.length - a.length);
182
+ const candidate = structuredClone(payload);
183
+ const trimmed = [];
184
+ const sizeOf = (extra) =>
185
+ Buffer.byteLength(stringifyJson(extra ? { ...candidate, truncated: extra } : candidate, pretty));
186
+ const segmentsOf = (path) =>
187
+ path.split('.').flatMap((part) => {
188
+ const m = /^(.*?)\[(\d+)\]$/.exec(part);
189
+ return m ? [m[1], Number(m[2])] : [part];
190
+ });
191
+ for (const slot of slots) {
192
+ const at = segmentsOf(slot.path);
193
+ let target = candidate;
194
+ for (let i = 0; i < at.length - 1; i += 1) target = target[at[i]];
195
+ if (target === null || typeof target !== 'object') continue;
196
+ target[at[at.length - 1]] = [];
197
+ trimmed.push({ field: slot.path, omitted: slot.length });
198
+ const extra = trimmed.length === 1
199
+ ? { field: trimmed[0].field, omitted: trimmed[0].omitted, limit: maxBytes }
200
+ : { fields: trimmed, limit: maxBytes };
201
+ if (sizeOf(extra) <= maxBytes) return { ...candidate, truncated: extra };
202
+ }
203
+ return null;
204
+ }
205
+
147
206
  export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
148
- const payload =
149
- value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
150
- ? { schemaVersion: SCHEMA_VERSION, ...value }
151
- : value;
152
- const text = `${pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)}\n`;
153
- // The same ceiling and the same announcement `emit` applies: a reader who asked for a
154
- // bounded answer gets a bounded answer and is told how much is missing, rather than a
155
- // half-parsed document it believes is whole.
207
+ const payload = withSchemaVersion(value);
208
+ const text = `${stringifyJson(payload, pretty)}\n`;
209
+ // JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
210
+ // true. Arrays shrink with a `truncated` record naming what lost how much, and a
211
+ // document no trimming can fit becomes a `MAX_BYTES` error envelope (Q10, ADR-105).
156
212
  if (maxBytes > 0 && Buffer.byteLength(text) > maxBytes) {
157
- const slice = Buffer.from(text).subarray(0, maxBytes).toString('utf8');
158
- const cut = slice.lastIndexOf('\n');
159
- stream.write(cut > 0 ? `${slice.slice(0, cut)}\n` : slice);
160
- stream.write(`TRUNCATED ${Buffer.byteLength(text) - maxBytes} bytes omitted, raise maxBytes\n`);
213
+ const fitted = fitJsonArrays(payload, maxBytes, pretty);
214
+ if (fitted !== null) {
215
+ stream.write(`${stringifyJson(fitted, pretty)}\n`);
216
+ return false;
217
+ }
218
+ const envelope = withSchemaVersion({
219
+ ...(payload !== null && typeof payload === 'object' && !Array.isArray(payload) && typeof payload.command === 'string'
220
+ ? { command: payload.command }
221
+ : {}),
222
+ ok: false,
223
+ error: {
224
+ code: 'MAX_BYTES',
225
+ message: `output exceeded the --max-bytes ceiling of ${maxBytes} bytes`,
226
+ remedy: 'rerun with --max-bytes 0 for the whole document',
227
+ },
228
+ truncated: { omitted: Buffer.byteLength(text) - maxBytes, limit: maxBytes },
229
+ });
230
+ stream.write(`${stringifyJson(envelope, pretty)}\n`);
161
231
  return false;
162
232
  }
163
233
  stream.write(text);
@@ -14,9 +14,11 @@
14
14
  // must match: npm pins the node_modules copy for us, but nothing pins a binary, so
15
15
  // the gate compares the running version against the pin and refuses on a mismatch.
16
16
 
17
+ import { execFileSync } from 'node:child_process';
17
18
  import { readFileSync } from 'node:fs';
18
19
  import { stat } from 'node:fs/promises';
19
- import { join } from 'node:path';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
20
22
 
21
23
  import { MANIFEST_FILE } from './name.mjs';
22
24
 
@@ -38,6 +40,52 @@ export function packageVersion() {
38
40
  }
39
41
  }
40
42
 
43
+ /** The directory this package is installed in, whatever resolved the import. */
44
+ function packageRootDir() {
45
+ return dirname(dirname(fileURLToPath(import.meta.url)));
46
+ }
47
+
48
+ /**
49
+ * The short commit this checkout is at, or null outside a git checkout (an
50
+ * installed tarball or wheel has no `.git`). Read-only and best-effort: a
51
+ * version string must never fail because the VCS is absent (Q05).
52
+ */
53
+ export function checkoutSha() {
54
+ try {
55
+ const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
56
+ cwd: packageRootDir(),
57
+ encoding: 'utf8',
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ }).trim();
60
+ return /^[0-9a-f]+$/.test(sha) ? sha : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * What `--version` prints: the package version, plus the checkout SHA when run
68
+ * from a source tree past its tag. A `main` 26 commits past v0.6.2 reporting
69
+ * bare `0.6.2` is the drift Q05 closes: the number alone claims a release the
70
+ * bytes are not (Q05).
71
+ */
72
+ export function describeVersion() {
73
+ const version = packageVersion();
74
+ const sha = checkoutSha();
75
+ if (!sha) return `simt-dev ${version}`;
76
+ try {
77
+ const tag = execFileSync('git', ['describe', '--tags', '--exact-match'], {
78
+ cwd: packageRootDir(),
79
+ encoding: 'utf8',
80
+ stdio: ['ignore', 'pipe', 'ignore'],
81
+ }).trim();
82
+ if (tag === `v${version}`) return `simt-dev ${version}`;
83
+ } catch {
84
+ // Not exactly on the release tag: fall through to the SHA suffix.
85
+ }
86
+ return `simt-dev ${version} (${sha})`;
87
+ }
88
+
41
89
  /**
42
90
  * Whether a repository has a package.json, i.e. whether it pins simt-dev as a
43
91
  * devDependency (npm) or must download the release binary (ADR-098). Async because the render
@@ -14,7 +14,8 @@
14
14
  // copied in and pinned by digest, never fetched at render time.
15
15
 
16
16
  import { execFile } from 'node:child_process';
17
- import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
17
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
18
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
18
19
  import { createHash } from 'node:crypto';
19
20
  import { basename, dirname, join } from 'node:path';
20
21
  import { promisify } from 'node:util';
@@ -545,8 +546,14 @@ export async function publishPack(dir, { apply = false } = {}) {
545
546
  return { pack: linted.pack, ok: true, published: true, skipped: false, planned, findings };
546
547
  }
547
548
 
548
- /** Write scaffold files into a directory that must not already exist. */
549
- export async function writeScaffold(dir, files) {
549
+ /**
550
+ * Write scaffold files into a directory that must not already exist.
551
+ *
552
+ * `root` is the repository the journal set belongs to — usually the scaffold's
553
+ * own parent, so `undo` runs where the files live. Recorded (R49) because a
554
+ * scaffold is repo files like any other write.
555
+ */
556
+ export async function writeScaffold(dir, files, root = dirname(dir)) {
550
557
  try {
551
558
  await stat(dir);
552
559
  throw new Error(
@@ -555,9 +562,11 @@ export async function writeScaffold(dir, files) {
555
562
  } catch (error) {
556
563
  if (error?.message?.startsWith('refusing to scaffold')) throw error;
557
564
  }
565
+ const journal = [];
558
566
  for (const file of files) {
559
567
  const abs = join(dir, file.path);
560
568
  await mkdir(dirname(abs), { recursive: true });
561
- await writeFile(abs, file.text, 'utf8');
569
+ await journaledWriteFile(root, journal, abs, file.text);
562
570
  }
571
+ await commitJournal(root, 'pack new', journal);
563
572
  }
@@ -20,10 +20,11 @@
20
20
  // the file was read. Status and dependency checks never touch it: a 1 MB
21
21
  // section that belongs to a later slice is not copied into the index.
22
22
 
23
- import { readdir, readFile, writeFile } from 'node:fs/promises';
23
+ import { readdir, readFile } from 'node:fs/promises';
24
24
  import { join } from 'node:path';
25
25
 
26
26
  import { CLI } from '../name.mjs';
27
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
27
28
  import { loadFormat } from './template.mjs';
28
29
 
29
30
  function clean(cell) {
@@ -100,10 +101,16 @@ function sliceKey(path, id) {
100
101
  * empty cell is the pipe that closes it. A further empty cell is real: the
101
102
  * evidence column is often blank, and stripping every trailing empty would
102
103
  * delete it and shift the state into the wrong column.
104
+ *
105
+ * A pipe inside a code span is content, not a separator: an evidence cell may
106
+ * carry a command line with a pipe in it, and splitting there shifts every
107
+ * later cell left by one (review 0003 Q25). Backticks toggle the span; a
108
+ * `\|` escape is content in either mode.
103
109
  */
104
110
  function splitRow(line) {
105
111
  const cells = [];
106
112
  let current = '';
113
+ let inCode = false;
107
114
  for (let i = 0; i < line.length; i += 1) {
108
115
  const char = line[i];
109
116
  if (char === '\\' && i + 1 < line.length) {
@@ -111,7 +118,12 @@ function splitRow(line) {
111
118
  i += 1;
112
119
  continue;
113
120
  }
114
- if (char === '|') {
121
+ if (char === '`') {
122
+ inCode = !inCode;
123
+ current += char;
124
+ continue;
125
+ }
126
+ if (char === '|' && !inCode) {
115
127
  cells.push(current);
116
128
  current = '';
117
129
  continue;
@@ -596,9 +608,16 @@ export async function planDone(root, id, { sha = null, test = null, state = null
596
608
  return { root, id, error: `${id} is no longer on line ${slice.line} of ${slice.docName}`, remedy: `run: ${CLI} plan status` };
597
609
  }
598
610
 
599
- const cells = splitRow(found.lines[found.index]);
600
- const width = Math.max(slice.columnCount, slice.stateIdx + 1, slice.evidenceIdx + 1, cells.length);
601
- while (cells.length < width) cells.push('');
611
+ const original = found.lines[found.index];
612
+ const cells = splitRow(original);
613
+ if (cells.length !== slice.columnCount) {
614
+ return {
615
+ root,
616
+ id,
617
+ error: `the row for ${id} has ${cells.length} cells; the table declares ${slice.columnCount}`,
618
+ remedy: `fix the row in ${slice.docName} — an unescaped pipe splits it — and retry`,
619
+ };
620
+ }
602
621
  const from = cells[slice.stateIdx] ?? '';
603
622
  cells[slice.stateIdx] = replaceState(from, display);
604
623
 
@@ -613,14 +632,18 @@ export async function planDone(root, id, { sha = null, test = null, state = null
613
632
  evidenceNote = 'no evidence column in this tracker; the sha was not recorded';
614
633
  }
615
634
 
616
- const rebuilt = `| ${cells.join(' | ')} |`;
617
- const original = found.lines[found.index];
635
+ // The rebuilt line keeps the original's ending: a CRLF file must not gain
636
+ // one LF-terminated line among its CRLF lines (review 0003 Q25).
637
+ const eol = original.endsWith('\r') ? '\r' : '';
638
+ const rebuilt = `| ${cells.join(' | ')} |${eol}`;
618
639
  const changedCells = splitRow(original).join('\0') !== cells.join('\0');
619
640
  const newLine = changedCells ? rebuilt : original;
620
641
  const wouldChange = newLine !== original;
621
642
  if (wouldChange && !dryRun) {
622
643
  found.lines[found.index] = newLine;
623
- await writeFile(slice.docPath, found.lines.join('\n'), 'utf8');
644
+ const journal = [];
645
+ await journaledWriteFile(root, journal, slice.docPath, found.lines.join('\n'));
646
+ await commitJournal(root, 'plan', journal);
624
647
  }
625
648
 
626
649
  const apply = `run: ${CLI} plan done ${id}${sha ? ` ${sha}` : ''}${display === format.doneWrite ? '' : ` --state ${JSON.stringify(display)}`}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
@@ -632,6 +655,12 @@ export async function planDone(root, id, { sha = null, test = null, state = null
632
655
  line: found.index + 1,
633
656
  from,
634
657
  to: cells[slice.stateIdx],
658
+ // The `--diff` sides for the one changed row (plan 0003 R50). The full file
659
+ // is not carried: one table row is the whole write, and a context-less hunk
660
+ // over the row says exactly what changed. Captured pre-write, so the diff
661
+ // is true in preview and in apply alike.
662
+ beforeLine: original,
663
+ afterLine: newLine,
635
664
  state: format.readState(display),
636
665
  wouldChange,
637
666
  changed: wouldChange && !dryRun,
@@ -23,7 +23,7 @@ const KNOWN = new Set(['$comment', 'name', 'directories', 'id', 'columns', 'stat
23
23
  const ID_KEYS = new Set(['pattern', 'headers']);
24
24
  const COLUMN_KEYS = new Set(['state', 'depends', 'title', 'evidence']);
25
25
  const DIRECTORY_KEYS = new Set(['kind', 'word', 'path']);
26
- const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies']);
26
+ const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies', 'role']);
27
27
  const DEPENDENCY_KEYS = new Set(['empty', 'separators', 'notes', 'group']);
28
28
  const WAVE_KEYS = new Set(['label', 'members']);
29
29
  const SECTION_KEYS = new Set(['depth']);
@@ -148,6 +148,7 @@ function compileStates(source, value) {
148
148
  const write = new Map();
149
149
  const known = new Set();
150
150
  const satisfies = new Set();
151
+ const roles = new Map();
151
152
  const order = [];
152
153
  const stateNames = [];
153
154
  for (let i = 0; i < value.length; i += 1) {
@@ -162,6 +163,13 @@ function compileStates(source, value) {
162
163
  }
163
164
  if (!Array.isArray(entry.read) || entry.read.length === 0) return bad(source, `${where}.read must list the words that mean this state`);
164
165
  if (known.has(entry.id)) return bad(source, `state ${entry.id} is listed twice`);
166
+ if (entry.role !== undefined && (typeof entry.role !== 'string' || !/^[a-z][a-z0-9-]*$/.test(entry.role))) {
167
+ return bad(source, `${where}.role must be a lower-case word`);
168
+ }
169
+ if (typeof entry.role === 'string') {
170
+ if (roles.has(entry.role)) return bad(source, `role ${entry.role} is claimed twice`);
171
+ roles.set(entry.role, entry.write.trim());
172
+ }
165
173
  const display = entry.write.trim();
166
174
  const aliases = new Set([entry.id, display.toLowerCase()]);
167
175
  for (const alias of entry.read) {
@@ -182,7 +190,7 @@ function compileStates(source, value) {
182
190
  else if (entry.satisfies != null && entry.satisfies !== false) return bad(source, `${where}.satisfies must be true or false`);
183
191
  }
184
192
  if (satisfies.size === 0) return bad(source, 'one state must have satisfies true, or nothing can ever be ready');
185
- return { read, write, known, satisfies, order, stateNames };
193
+ return { read, write, known, satisfies, roles, order, stateNames };
186
194
  }
187
195
 
188
196
  function compile(body, source, root) {
@@ -262,7 +270,7 @@ function compile(body, source, root) {
262
270
 
263
271
  const byWord = new Map(directories.directories.map((dir) => [dir.word, dir.kind]));
264
272
  const doneWrite = body.states.find((entry) => entry.satisfies === true).write.trim();
265
- const { read, write } = states;
273
+ const { read, write, roles } = states;
266
274
 
267
275
  return {
268
276
  format: {
@@ -273,6 +281,12 @@ function compile(body, source, root) {
273
281
  order: states.order,
274
282
  ready,
275
283
  doneWrite,
284
+ // The display a command moves a row to when work on it starts: the state
285
+ // carrying the `started` role, or null when no state claims it (review
286
+ // 0003 Q23). A null here is not an error — the caller falls back to the
287
+ // in-progress spelling it can read, and `plan done` fails closed on a
288
+ // word the template never defined.
289
+ startedWrite: roles.get('started') ?? null,
276
290
  stateNames: states.stateNames,
277
291
  satisfies: states.satisfies,
278
292
  known: states.known,
@@ -292,9 +306,17 @@ function compile(body, source, root) {
292
306
  headingRe: new RegExp(`^(#{1,${depth}})\\s+(.*\\S)\\s*$`),
293
307
  readState(raw) {
294
308
  const plain = String(raw ?? '').replace(/\*\*/g, '').trim().toLowerCase();
309
+ if (plain === '') return 'unknown';
295
310
  if (read.has(plain)) return read.get(plain);
296
- const token = plain.split(/[\s(]/)[0];
297
- return read.get(token) ?? (token || 'unknown');
311
+ // A cell may carry a note after the state — "In progress (since Monday)".
312
+ // The state is the longest alias that prefixes the text: "in progress"
313
+ // must win over a shorter alias it starts, and a word no state claims
314
+ // is unknown, not a state (review 0003 Q25).
315
+ let best = '';
316
+ for (const alias of read.keys()) {
317
+ if (alias.length > best.length && plain.startsWith(alias)) best = alias;
318
+ }
319
+ return best !== '' ? read.get(best) : 'unknown';
298
320
  },
299
321
  canonical(input) {
300
322
  return write.get(String(input ?? '').trim().toLowerCase()) ?? null;
package/src/plugins.mjs CHANGED
@@ -24,13 +24,24 @@
24
24
  // leaves the machine it was built on. The command says so rather than pretending.
25
25
 
26
26
  import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
27
- import { basename, join } from 'node:path';
27
+ import { basename, dirname, join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { readBefore } from './diff.mjs';
28
30
  import { hookDocument } from './agenthooks/render.mjs';
29
31
  import { desiredRegion } from './projection/rules.mjs';
30
32
  import { PACKAGE_NAME, BIN_REL } from './name.mjs';
31
33
 
32
34
  /** The kinds `simt plugin` builds, in the order the help line names them. */
33
- export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor'];
35
+ export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor', 'opencode'];
36
+
37
+ /**
38
+ * The package this code ships in: the runtime a generated package copies. Resolved
39
+ * from the module URL, not from the consumer's cwd, so `plugin` built with
40
+ * `--root <consumer>` still ships this tool rather than the consumer's files (Q03).
41
+ */
42
+ function packageRoot() {
43
+ return dirname(dirname(fileURLToPath(import.meta.url)));
44
+ }
34
45
 
35
46
  /** Where the runtime copy lands inside a package, relative to the package root. */
36
47
  function runtimeDir(kind) {
@@ -60,7 +71,11 @@ function rootFlag(kind) {
60
71
 
61
72
  /** Where the hooks document lands inside a package, relative to the package root. */
62
73
  function hooksPath(kind) {
63
- return kind === 'cursor' ? join('.cursor', 'hooks.json') : join('hooks', 'hooks.json');
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');
64
79
  }
65
80
 
66
81
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
@@ -71,7 +86,7 @@ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
71
86
  * it is many files; this returns only the handful that are authored here.
72
87
  */
73
88
  async function packageFiles(kind, { root = process.cwd(), out } = {}) {
74
- const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
89
+ const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
75
90
  const command = `${entryPoint(kind)} agent-hook pre-tool-use --harness ${kind}${rootFlag(kind)}`;
76
91
  const files = [];
77
92
 
@@ -128,12 +143,21 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
128
143
  throw new Error(`unknown package kind \`${kind}\`; one of: ${PLUGIN_KINDS.join(', ')}`);
129
144
  }
130
145
 
131
- const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
146
+ const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
132
147
  const files = await packageFiles(kind, { root, out });
133
148
  const notes = [
134
149
  'the skill registry is workspace data (ADR-061) and is not shipped; run `simt skills index` in the profile to list what a workspace would offer',
135
150
  ];
136
151
 
152
+ // The `--diff` before-sides, read before anything is written so the diff is
153
+ // true in preview and in apply alike (R50). Always captured: the files are a
154
+ // handful of small generated configs. The runtime copy below is excluded — it
155
+ // is a mechanical copy of this tool's own sources, not planned content, and
156
+ // diffing it would reprint the tool into the review.
157
+ for (const f of files) {
158
+ f.before = await readBefore(join(out, f.path));
159
+ }
160
+
137
161
  if (apply) {
138
162
  const runtimeTarget = join(out, runtimeDir(kind));
139
163
  await mkdir(runtimeTarget, { recursive: true });
@@ -142,10 +166,11 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
142
166
  // dies on the first MCP call: the server reads templates/ and rules/, which a
143
167
  // bin+src-only copy leaves behind. The `files` field is the definition of "a working
144
168
  // copy of this tool", so it is the set a package ships.
169
+ const runtimeRoot = packageRoot();
145
170
  for (const part of pkg.files) {
146
- await cp(join(root, part), join(runtimeTarget, part), { recursive: true });
171
+ await cp(join(runtimeRoot, part), join(runtimeTarget, part), { recursive: true });
147
172
  }
148
- await cp(join(root, 'package.json'), join(runtimeTarget, 'package.json'));
173
+ await cp(join(runtimeRoot, 'package.json'), join(runtimeTarget, 'package.json'));
149
174
  for (const f of files) {
150
175
  const target = join(out, f.path);
151
176
  await mkdir(join(target, '..'), { recursive: true });
@@ -158,7 +183,7 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
158
183
  out,
159
184
  apply,
160
185
  runtime: { target: runtimeDir(kind), parts: [...pkg.files, 'package.json'] },
161
- files: files.map(({ text, ...rest }) => rest),
186
+ files,
162
187
  notes,
163
188
  };
164
189
  }