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
@@ -1,4 +1,4 @@
1
- // simt plugin <claude|gemini|cursor> --out <dir> [--yes]
1
+ // simt plugin <claude|gemini|cursor|opencode> --out <dir> [--yes]
2
2
  //
3
3
  // Build one harness package from the same tables `agent-hooks install` writes into a
4
4
  // repository, so a package and a repository install cannot drift apart. A dry run prints
@@ -9,6 +9,7 @@
9
9
  import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
10
10
  import { CLI } from '../name.mjs';
11
11
  import { buildPackage, PLUGIN_KINDS } from '../plugins.mjs';
12
+ import { diffField, emitDiffs } from '../diff.mjs';
12
13
 
13
14
  export async function pluginCommand(args, flags) {
14
15
  const kind = args[0];
@@ -29,6 +30,17 @@ export async function pluginCommand(args, flags) {
29
30
  const root = flags.root ?? process.cwd();
30
31
  const plan = await buildPackage(kind, { root, out, apply });
31
32
 
33
+ // The `--diff` pairs: the generated files, created or refreshed (R50). The
34
+ // before-sides ride on the plan from `buildPackage`, read before anything was
35
+ // written, so the diff is true in preview and in apply alike.
36
+ const diffs =
37
+ flags.diff === true
38
+ ? plan.files.map((f) => ({ path: f.path, before: f.before ?? null, after: f.text }))
39
+ : [];
40
+ // The file bytes travel in `diff`, not in the file list: a payload that
41
+ // carried every generated file twice would be a document nobody diffs.
42
+ const publicFiles = plan.files.map(({ text, before, ...rest }) => rest);
43
+
32
44
  if (isJson(flags)) {
33
45
  emitJson({
34
46
  ok: true,
@@ -36,8 +48,9 @@ export async function pluginCommand(args, flags) {
36
48
  out,
37
49
  dryRun: !apply,
38
50
  runtime: plan.runtime,
39
- files: plan.files,
51
+ files: publicFiles,
40
52
  notes: plan.notes,
53
+ ...diffField(diffs, flags),
41
54
  });
42
55
  return EXIT.OK;
43
56
  }
@@ -57,5 +70,7 @@ export async function pluginCommand(args, flags) {
57
70
  ].join('\n'),
58
71
  { maxBytes: flags.maxBytes ?? 0 },
59
72
  );
73
+ // After the report: the report says what, the diff shows it (R50).
74
+ emitDiffs(diffs, flags);
60
75
  return EXIT.OK;
61
76
  }
@@ -0,0 +1,45 @@
1
+ // `simt pr-body [--base <ref>]` (plan 0003 R55).
2
+ //
3
+ // The report layer over `src/prbody.mjs`: a markdown PR body on stdout, one
4
+ // JSON document under `--json`. Read-only like `evidence` and `review` — it
5
+ // names the tree, reads the ledger and the check cache, and writes nothing,
6
+ // so there is no `--yes`, no `--diff` and no journal entry to record.
7
+ //
8
+ // The exit code never carries the verdict. A report that failed when the body
9
+ // said BLOCK would be a gate by another name (the same reason `review`
10
+ // exits 0 on an unverified reply), and a gate this command never asked to be.
11
+
12
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
+ import { CLI } from '../name.mjs';
14
+ import { buildPrBody, renderPrBody } from '../prbody.mjs';
15
+
16
+ export async function prBodyCommand(args, flags) {
17
+ const root = flags.root ?? process.cwd();
18
+ if (args.length > 0) {
19
+ return fail(`pr-body takes no positional arguments`, EXIT.USAGE, `run: ${CLI} pr-body [--base <ref>]`);
20
+ }
21
+ const report = await buildPrBody(root, { base: flags.base ?? null });
22
+ if (report.error !== null) {
23
+ return fail(report.error, EXIT.USAGE, `run: ${CLI} pr-body --base <ref> with a ref git knows`);
24
+ }
25
+ if (isJson(flags)) {
26
+ emitJson(
27
+ {
28
+ ok: true,
29
+ command: 'pr-body',
30
+ base: report.base,
31
+ tree: report.tree,
32
+ evidence: report.evidence,
33
+ decisions: report.decisions,
34
+ paths: report.paths,
35
+ gates: report.gates,
36
+ verdict: report.verdict,
37
+ unreviewed: report.unreviewed,
38
+ },
39
+ { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
40
+ );
41
+ return EXIT.OK;
42
+ }
43
+ emit(renderPrBody(report), { maxBytes: flags.maxBytes ?? 0 });
44
+ return EXIT.OK;
45
+ }
@@ -18,6 +18,9 @@ export async function indexCommand(args, flags) {
18
18
  allowRemote: flags.allowRemote === true,
19
19
  });
20
20
  } catch (error) {
21
+ if (error?.code === 'INDEX_BUSY') {
22
+ return fail(`could not index this repository (${error.message})`, EXIT.FAIL, 'wait a moment and run the index again');
23
+ }
21
24
  return fail(
22
25
  `could not index this repository (${error.message})`,
23
26
  EXIT.UNSUPPORTED,
@@ -36,6 +39,7 @@ export async function indexCommand(args, flags) {
36
39
  `files ${result.files}`,
37
40
  `updated ${result.updated}`,
38
41
  `removed ${result.removed}`,
42
+ `skipped ${result.skipped ?? 0}`,
39
43
  `symbols ${result.symbols}`,
40
44
  `edges ${result.edges}`,
41
45
  `ms ${result.ms}`,
@@ -20,7 +20,9 @@ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
20
20
  import { loadRules, select } from '../projection/project.mjs';
21
21
  import { loadMdcRules } from '../projection/mdc.mjs';
22
22
  import { fingerprint } from '../resolve/fingerprint.mjs';
23
- import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
23
+ import { byId, citations, loadDecisionsCached, redactEstate } from '../decisions/index.mjs';
24
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
25
+ import { decisionsDir } from '../decisions/dir.mjs';
24
26
  import { nearMatches } from '../resolve/near.mjs';
25
27
  import { CLI } from '../name.mjs';
26
28
 
@@ -87,11 +89,10 @@ async function loadPerSurfaceRules() {
87
89
 
88
90
  export async function listCommand(args, flags) {
89
91
  const root = flags.root ?? null;
90
- const { rules, broken } = await load(root);
91
-
92
92
  // With --root the question becomes "which of these apply to me", which is a
93
93
  // different answer from the catalogue and the one an agent usually wants.
94
- const selection = root ? await selectionFor(rules, root) : null;
94
+ const { rules, broken, notes } = await rulesCatalogue(root, { selectFor: root });
95
+ const selection = root ? new Set(rules.filter((r) => r.selected).map((r) => r.id)) : null;
95
96
 
96
97
  if (isJson(flags)) {
97
98
  emitJson(
@@ -99,19 +100,8 @@ export async function listCommand(args, flags) {
99
100
  ok: broken.length === 0,
100
101
  command: 'rules list',
101
102
  root,
102
- rules: sorted(rules).map((r) => ({
103
- id: r.id,
104
- title: r.title,
105
- origin: r.origin,
106
- source: r.source,
107
- applies: r.applies,
108
- fallback: r.fallback,
109
- bytes: r.bytes,
110
- error: r.error,
111
- adrs: citations(r.body),
112
- selected: selection ? selection.selected.has(r.id) : null,
113
- })),
114
- notes: selection?.notes ?? [],
103
+ rules,
104
+ notes,
115
105
  },
116
106
  );
117
107
  return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
@@ -145,7 +135,7 @@ export async function listCommand(args, flags) {
145
135
  ];
146
136
 
147
137
  for (const rule of rows) {
148
- const mark = selection && selection.selected.has(rule.id) ? '*' : ' ';
138
+ const mark = selection && selection.has(rule.id) ? '*' : ' ';
149
139
  lines.push(
150
140
  render(mark, rule.id, kb(rule.bytes), rule.origin, appliesLabel(rule), rule.title ?? '(no title)'),
151
141
  );
@@ -156,7 +146,7 @@ export async function listCommand(args, flags) {
156
146
  }
157
147
  if (selection) {
158
148
  lines.push('', `* selected for ${root}`);
159
- for (const note of selection.notes) lines.push(` note ${note}`);
149
+ for (const note of notes) lines.push(` note ${note}`);
160
150
  }
161
151
  if (root) {
162
152
  const locals = rules.filter((r) => r.origin === 'local');
@@ -173,15 +163,152 @@ async function selectionFor(rules, root) {
173
163
  return { selected: new Set(selected.map((r) => r.id)), notes };
174
164
  }
175
165
 
166
+ // --- cores: one computation for the CLI and the MCP tools (Q15) ----------------
167
+ //
168
+ // `rules_list` and `rules_why` in the MCP surface rebuilt this catalogue from the
169
+ // same modules with narrower loads: the MCP list missed the per-surface rules and
170
+ // the MCP why failed on them, because neither shared the catalogue below. The
171
+ // commands render these facts for terminals; the tools return them as JSON. The
172
+ // CLI keeps its field names; the tools adopt them.
173
+ //
174
+ // A core throws when it cannot answer: the message names the problem, `remedy`
175
+ // names the next command, and `exitCode` is the exit the CLI leaves with.
176
+ function rulesFailure(message, remedy, exitCode) {
177
+ throw Object.assign(new Error(message), { remedy, exitCode });
178
+ }
179
+
180
+ /**
181
+ * The merged decision index: the package's shipped records underlay the
182
+ * repository's own, which win on an id collision (review 0003 Q34).
183
+ *
184
+ * Before this, a consumer with its own ADR-001/002 read `rules why core` as
185
+ * settled by *their* ADR-001 while the package's ADR-003…009 — the ones the
186
+ * shipped rules actually cite — reported MISSING. The overlay keeps both
187
+ * visible: every decision carries which layer it came from, so a same-number
188
+ * collision reads as two records, never as one answer wearing the wrong title.
189
+ *
190
+ * @returns {{ok: boolean, decisions: object[], errors: string[], origins: Record<string,'repo'|'package'>}}
191
+ */
192
+ async function loadDecisionsFor(root, all) {
193
+ // Q55: one resolution (manifest key, then detection) and the shared cache,
194
+ // so `rules why` answers from the repository's own records without re-parsing
195
+ // every record on every call. No root means the package's own records.
196
+ const pkg = await loadDecisionsCached({ root, dir: DECISIONS_DIR, all });
197
+ if (!root) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
198
+ const relDir = await decisionsDir(root);
199
+ const absDir = join(root, relDir);
200
+ const hasLocal = (await readdir(absDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n));
201
+ if (!hasLocal) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
202
+ const local = await loadDecisionsCached({ root, dir: absDir, all });
203
+ const merged = new Map(pkg.decisions.map((d) => [d.id, d]));
204
+ const origins = Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package']));
205
+ for (const d of local.decisions) {
206
+ merged.set(d.id, d);
207
+ origins[d.id] = 'repo';
208
+ }
209
+ return { ok: pkg.ok && local.ok, decisions: [...merged.values()], errors: [...pkg.errors, ...local.errors], origins };
210
+ }
211
+
212
+ /** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
213
+ export async function rulesCatalogue(root, { selectFor = null } = {}) {
214
+ const { rules, broken } = await load(root);
215
+ const selection = selectFor ? await selectionFor(rules, selectFor) : null;
216
+ return {
217
+ rules: sorted(rules).map((r) => ({
218
+ id: r.id,
219
+ title: r.title,
220
+ origin: r.origin,
221
+ source: r.source,
222
+ applies: r.applies,
223
+ optIn: r.optIn ?? false,
224
+ fallback: r.fallback,
225
+ bytes: r.bytes,
226
+ error: r.error,
227
+ adrs: citations(r.body),
228
+ selected: selection ? selection.selected.has(r.id) : null,
229
+ })),
230
+ broken,
231
+ notes: selection?.notes ?? [],
232
+ };
233
+ }
234
+
235
+ /**
236
+ * The provenance facts for one rule or one decision record, in the shape the
237
+ * JSON documents carry. Text renders from the same facts; nothing re-derives.
238
+ */
239
+ export async function rulesWhyFacts(root, what, { all = false } = {}) {
240
+ const { rules } = await load(root);
241
+ if (/^ADR-\d{3,}$/i.test(what)) {
242
+ const id = what.toUpperCase();
243
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
244
+ if (!ok) {
245
+ rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
246
+ }
247
+ const record = decisions.find((x) => x.id === id) ?? null;
248
+ // Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
249
+ // tree, which resolves to nothing in the repo the reader is standing in. The
250
+ // remedy must be a command they can run (Q34: the decisions list, not the
251
+ // rules list - the reader asked about a decision, not a rule).
252
+ if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules decisions`, EXIT.USAGE);
253
+ const replacement = record.supersededBy ? decisions.find((x) => x.id === record.supersededBy) : null;
254
+ return {
255
+ direction: 'decision',
256
+ id,
257
+ adr: { ...record, origin: origins[id] ?? 'package' },
258
+ origins,
259
+ supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
260
+ inForce: replacement
261
+ ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
262
+ : { id: record.id, status: record.status, summary: record.summary },
263
+ rules: rules.filter((r) => citations(r.body).includes(id)).map((r) => r.id),
264
+ };
265
+ }
266
+ const rule = rules.find((r) => r.id === what);
267
+ if (!rule) {
268
+ const near = nearMatches(what, rules.map((r) => r.id));
269
+ rulesFailure(
270
+ `no rule with id "${what}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
271
+ `run: ${CLI} rules list`,
272
+ EXIT.USAGE,
273
+ );
274
+ }
275
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
276
+ if (!ok) {
277
+ rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
278
+ }
279
+ const index = byId(decisions);
280
+ const cited = citations(rule.body);
281
+ const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
282
+ return {
283
+ direction: 'rule',
284
+ id: rule.id,
285
+ rule: rule.id,
286
+ title: rule.title ?? null,
287
+ source: rule.source,
288
+ origin: rule.origin,
289
+ adrs: cited.map((c) => (index.has(c) ? { ...index.get(c), origin: origins[c] ?? 'package' } : { id: c, missing: true })),
290
+ uncited: cited.length === 0,
291
+ others: others.map((r) => r.id),
292
+ origins,
293
+ };
294
+ }
295
+
176
296
  // --- show ---------------------------------------------------------------------
177
297
 
178
298
  export async function showCommand(args, flags) {
179
299
  const id = args[0];
180
300
  if (!id) return fail('rules show needs a rule id', EXIT.USAGE, `usage: ${CLI} rules show <id>`);
181
301
 
182
- const { rules, broken } = await load(flags.root ?? null);
302
+ const { rules } = await load(flags.root ?? null);
183
303
  const rule = rules.find((r) => r.id === id);
184
- if (!rule) return unknown(id, rules);
304
+ if (!rule) {
305
+ const near = nearMatches(id, rules.map((r) => r.id));
306
+ return fail(
307
+ `no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
308
+ EXIT.USAGE,
309
+ `run: ${CLI} rules list`,
310
+ );
311
+ }
185
312
 
186
313
  const adrs = citations(rule.body);
187
314
  if (isJson(flags)) {
@@ -218,47 +345,25 @@ export async function whyCommand(args, flags) {
218
345
  const what = args[0];
219
346
  if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
220
347
 
221
- const { rules, broken } = await load(flags.root ?? null);
222
- const localDir = flags.root ? join(flags.root, 'docs', 'decisions') : null;
223
- const hasLocal = localDir
224
- ? (await readdir(localDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
225
- : false;
226
- const { ok, decisions, errors } = await loadDecisions({
227
- all: flags.all === true,
228
- ...(hasLocal ? { dir: localDir } : {}),
229
- });
230
- if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
231
-
232
- return what.match(/^ADR-\d{3,}$/i)
233
- ? adrDirection(what.toUpperCase(), rules, decisions, flags)
234
- : ruleDirection(what, rules, decisions, flags, broken);
235
- }
236
-
237
- async function ruleDirection(id, rules, decisions, flags, broken) {
238
- const rule = rules.find((r) => r.id === id);
239
- if (!rule) return unknown(id, rules);
240
-
241
- const cited = citations(rule.body);
242
- const index = byId(decisions);
348
+ let facts;
349
+ try {
350
+ facts = await rulesWhyFacts(flags.root ?? null, what, { all: flags.all === true });
351
+ } catch (error) {
352
+ return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
353
+ }
243
354
 
244
355
  if (isJson(flags)) {
245
- emitJson(
246
- {
247
- ok: true,
248
- command: 'rules why',
249
- rule: rule.id,
250
- source: rule.source,
251
- origin: rule.origin,
252
- adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
253
- uncited: cited.length === 0,
254
- },
255
- );
356
+ emitJson({ ok: true, command: 'rules why', ...facts });
256
357
  return EXIT.OK;
257
358
  }
258
359
 
259
- const lines = [`${rule.id} - ${rule.title ?? '(no title)'}`, '', `source ${rule.source} (${rule.origin})`];
360
+ return facts.direction === 'decision' ? adrDirection(facts, flags) : ruleDirection(facts, flags);
361
+ }
362
+
363
+ function ruleDirection(facts, flags) {
364
+ const lines = [`${facts.rule} - ${facts.title ?? '(no title)'}`, '', `source ${facts.source} (${facts.origin})`];
260
365
 
261
- if (cited.length === 0) {
366
+ if (facts.uncited) {
262
367
  lines.push(
263
368
  '',
264
369
  'provenance NONE',
@@ -269,14 +374,14 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
269
374
  return EXIT.OK;
270
375
  }
271
376
 
272
- lines.push('', `settled by ${cited.length} decision${cited.length === 1 ? '' : 's'}`);
273
- for (const adr of cited) {
274
- const d = index.get(adr);
275
- if (!d) {
276
- lines.push(` ${adr} MISSING - the rule cites it, but this installation has no record of it`);
377
+ lines.push('', `settled by ${facts.adrs.length} decision${facts.adrs.length === 1 ? '' : 's'}`);
378
+ for (const d of facts.adrs) {
379
+ if (d.missing) {
380
+ lines.push(` ${d.id} MISSING - the rule cites it, but neither this repo nor this installation has a record of it`);
381
+ lines.push(` remedy: list the known decisions with: ${CLI} rules decisions`);
277
382
  continue;
278
383
  }
279
- lines.push(` ${d.id} ${d.title}`);
384
+ lines.push(` ${d.id} [${d.origin ?? 'package'}] ${d.title}`);
280
385
  lines.push(` ${d.status}`);
281
386
  if (d.supersededBy) {
282
387
  // Never print a reversed ruling as if it were current.
@@ -286,52 +391,24 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
286
391
 
287
392
  // The reverse edge is what makes this useful for maintaining the set: which
288
393
  // rules would a change to one decision touch?
289
- const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
290
- if (others.length > 0) {
291
- lines.push('', `shares a decision with ${others.map((r) => r.id).join(', ')}`);
394
+ if (facts.others.length > 0) {
395
+ lines.push('', `shares a decision with ${facts.others.join(', ')}`);
292
396
  }
293
397
 
294
398
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
295
399
  return EXIT.OK;
296
400
  }
297
401
 
298
- async function adrDirection(adr, rules, decisions, flags) {
299
- const d = decisions.find((x) => x.id === adr);
300
- if (!d) {
301
- // Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
302
- // tree, which resolves to nothing in the repo the reader is standing in. The
303
- // remedy must be a command they can run.
304
- return fail(`no decision record ${adr}`, EXIT.USAGE, `list the known decisions with: ${CLI} rules list`);
305
- }
306
-
307
- const restates = rules.filter((r) => citations(r.body).includes(d.id));
308
-
309
- if (isJson(flags)) {
310
- // The machine payload answers the same question as the text one. A caller reading
311
- // `adr.summary.ruled` off a superseded record would get the reversal, so the
312
- // effective ruling is carried alongside rather than instead: the record asked about
313
- // is still named, and what is in force is named separately.
314
- const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
315
- emitJson(
316
- {
317
- ok: true,
318
- command: 'rules why',
319
- adr: d,
320
- supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
321
- inForce: replacement
322
- ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
323
- : { id: d.id, status: d.status, summary: d.summary },
324
- rules: restates.map((r) => r.id),
325
- },
326
- );
327
- return EXIT.OK;
328
- }
402
+ function adrDirection(facts, flags) {
403
+ const d = facts.adr;
404
+ const restates = facts.rules;
329
405
 
330
406
  const lines = [
331
407
  `${d.id} - ${d.title}`,
332
408
  '',
409
+ `origin ${d.origin ?? 'package'}${d.origin === 'repo' ? ' (this repository overlays the shipped record of the same number)' : ''}`,
333
410
  `status ${d.status}`,
334
- `restates ${restates.length > 0 ? restates.map((r) => r.id).join(', ') : '(no rule cites it)'}`,
411
+ `restates ${restates.length > 0 ? restates.join(', ') : '(no rule cites it)'}`,
335
412
  ];
336
413
  if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
337
414
  if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
@@ -360,7 +437,7 @@ async function adrDirection(adr, rules, decisions, flags) {
360
437
  // attached, and an agent that skims the ruling and not the banner acts on the
361
438
  // reversal. This is the failure the decision index's own header names: a provenance
362
439
  // feature that proves provenance but not currency.
363
- const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
440
+ const replacement = facts.replacement;
364
441
  const effective = replacement ?? d;
365
442
 
366
443
  if (replacement) {
@@ -415,11 +492,48 @@ async function adrDirection(adr, rules, decisions, flags) {
415
492
  return EXIT.OK;
416
493
  }
417
494
 
418
- function unknown(id, rules) {
419
- const near = nearMatches(id, rules.map((r) => r.id));
420
- return fail(
421
- `no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
422
- EXIT.USAGE,
423
- `run: ${CLI} rules list`,
424
- );
495
+ // --- decisions ------------------------------------------------------------------
496
+
497
+ /**
498
+ * `simt rules decisions`: every known decision record, both layers.
499
+ *
500
+ * The shipped records underlay the repository's own, which win on a number
501
+ * collision (review 0003 Q34). The column that matters is `origin`: a reader in
502
+ * a consumer repository must be able to tell the package's ADR-001 from their
503
+ * own, because they are two records that happen to share a number.
504
+ */
505
+ export async function decisionsCommand(args, flags) {
506
+ const root = flags.root ?? null;
507
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, flags.all === true);
508
+ if (!ok) {
509
+ return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable to list them');
510
+ }
511
+ const rows = [...decisions]
512
+ .sort((a, b) => Number(a.id.replace(/^ADR-/i, '')) - Number(b.id.replace(/^ADR-/i, '')))
513
+ .map((d) => ({
514
+ id: d.id,
515
+ title: d.title,
516
+ status: d.status,
517
+ origin: origins[d.id] ?? 'package',
518
+ supersededBy: d.supersededBy ?? null,
519
+ file: d.file,
520
+ }));
521
+
522
+ if (isJson(flags)) {
523
+ emitJson({ ok: true, command: 'rules decisions', root, decisions: rows });
524
+ return EXIT.OK;
525
+ }
526
+
527
+ const repo = rows.filter((r) => r.origin === 'repo').length;
528
+ const lines = [
529
+ `${rows.length} decisions${root && repo > 0 ? ` (${repo} from this repo overlay the shipped set)` : ''}`,
530
+ '',
531
+ 'id origin title',
532
+ ];
533
+ for (const r of rows) {
534
+ const head = r.status.length > 80 ? `${r.status.slice(0, 80)}...` : r.status;
535
+ lines.push(`${r.id.padEnd(8)} ${r.origin.padEnd(7)} ${r.title} — ${head}`);
536
+ }
537
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
538
+ return EXIT.OK;
425
539
  }
@@ -11,10 +11,12 @@
11
11
  // is `doctor`'s job rather than a third subcommand: a question asked about a repository is
12
12
  // answered by the command that already reports on a repository (ADR-084).
13
13
 
14
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
15
15
  import { CLI, MANIFEST_FILE } from '../name.mjs';
16
16
  import { targetRoot } from '../workspace.mjs';
17
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
17
18
  import {
19
+ injectShared,
18
20
  intersect,
19
21
  sharedBody,
20
22
  sharedCandidates,
@@ -25,7 +27,7 @@ import {
25
27
  sharedTargets,
26
28
  writeSharedFile,
27
29
  } from '../projection/shared.mjs';
28
- import { buildManifest, declaredShared, readManifest, writeManifest } from '../projection/manifest.mjs';
30
+ import { buildManifest, declaredShared, manifestPath, manifestText, readManifest, writeManifest } from '../projection/manifest.mjs';
29
31
  import { desiredRegion } from '../projection/rules.mjs';
30
32
 
31
33
  /**
@@ -241,7 +243,31 @@ export async function sharedInstallCommand(_args, flags) {
241
243
  manifests.push({ root, action: 'declared', manifest: built });
242
244
  }
243
245
 
244
- if (flags.yes !== true) {
246
+ // The `--diff` pairs, read before anything is written so the before side is
247
+ // true in preview and in apply alike (R50). Home-directory copies through the
248
+ // same `injectShared` the write applies, manifest declarations through
249
+ // `manifestText`; paste targets carry no file and are printed in full below.
250
+ let diffs = [];
251
+ if (flags.diff === true) {
252
+ for (const t of targets) {
253
+ if (t.path === null) continue;
254
+ const before = await readBefore(t.path);
255
+ diffs.push({ path: t.path, before, after: injectShared(before ?? '', shared, meta).text });
256
+ }
257
+ for (const row of manifests) {
258
+ if (row.action !== 'declared') continue;
259
+ diffs.push({
260
+ path: `${basename(row.root)}/${MANIFEST_FILE}`,
261
+ before: await readBefore(await manifestPath(row.root)),
262
+ after: manifestText(row.manifest),
263
+ });
264
+ }
265
+ }
266
+
267
+ // `--yes --dry-run` is a rehearsal, not consent: only writeMode decides, so the
268
+ // `--dry-run` promise holds for the one command that writes into `$HOME` (Q09).
269
+ const { dryRun, apply } = writeMode(flags);
270
+ if (!apply) {
245
271
  // Nothing above this line writes, and nothing below it runs without the reader's
246
272
  // consent — because one of the two writes lands in their home directory and the other
247
273
  // edits repositories other people may be reviewing. The states printed here come from
@@ -259,12 +285,13 @@ export async function sharedInstallCommand(_args, flags) {
259
285
  emitJson({
260
286
  ok: false,
261
287
  command: 'shared install',
262
- mode: 'preview',
288
+ mode: dryRun ? 'dry-run' : 'preview',
263
289
  roots,
264
290
  shared,
265
291
  divergent,
266
292
  targets: states,
267
293
  manifests: manifests.map(({ root: r, action, detail }) => ({ root: r, action, detail: detail ?? null })),
294
+ ...diffField(diffs, flags),
268
295
  });
269
296
  return EXIT.FAIL;
270
297
  }
@@ -277,10 +304,12 @@ export async function sharedInstallCommand(_args, flags) {
277
304
  : ` ${s.state.padEnd(9)}${s.path}`,
278
305
  ),
279
306
  `manifest ${manifests.map((m) => `${m.action}:${basename(m.root)}`).join(' ')}`,
280
- `nothing written; re-run with: ${CLI} shared install --yes`,
307
+ dryRun ? 'dry-run nothing written' : `nothing written; re-run with: ${CLI} shared install --yes`,
281
308
  ].join('\n'),
282
309
  { maxBytes: flags.maxBytes ?? 0 },
283
310
  );
311
+ // After the report: the report says what, the diff shows it (R50).
312
+ emitDiffs(diffs, flags);
284
313
  return EXIT.FAIL;
285
314
  }
286
315
 
@@ -326,6 +355,8 @@ export async function sharedInstallCommand(_args, flags) {
326
355
  }
327
356
  lines.push(`verify ${CLI} doctor reports the copy as current, and ${CLI} tokens --repos --root . reports what is left`);
328
357
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
358
+ // After the report: the report says what, the diff shows it (R50).
359
+ emitDiffs(diffs, flags);
329
360
  return EXIT.OK;
330
361
  }
331
362