simt-dev 0.6.0 → 0.6.3

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 (79) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
  6. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  7. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  8. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  9. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  10. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  11. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  12. package/docs/decisions/README.md +44 -0
  13. package/package.json +1 -1
  14. package/rules/mdc/preview-postflight.mdc +5 -6
  15. package/rules/stacks/android.md +7 -8
  16. package/rules/stacks/cloudflare-workers.md +1 -1
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/jvm.md +1 -1
  19. package/rules/stacks/react-native.md +3 -5
  20. package/rules/stacks/react-web.md +9 -9
  21. package/src/agenthooks/decide.mjs +101 -10
  22. package/src/agenthooks/policy.mjs +81 -5
  23. package/src/agenthooks/shell.mjs +523 -0
  24. package/src/argv.mjs +54 -9
  25. package/src/ci/workflow.mjs +7 -5
  26. package/src/commands/agenthook.mjs +25 -3
  27. package/src/commands/ci.mjs +28 -3
  28. package/src/commands/completions.mjs +27 -0
  29. package/src/commands/decide.mjs +14 -1
  30. package/src/commands/doctor.mjs +367 -0
  31. package/src/commands/explain.mjs +52 -0
  32. package/src/commands/help.mjs +66 -17
  33. package/src/commands/hooks.mjs +208 -60
  34. package/src/commands/ignore.mjs +152 -0
  35. package/src/commands/index.mjs +54 -100
  36. package/src/commands/init.mjs +82 -12
  37. package/src/commands/lease.mjs +136 -0
  38. package/src/commands/lessons.mjs +12 -0
  39. package/src/commands/mcp.mjs +2 -2
  40. package/src/commands/pack.mjs +9 -0
  41. package/src/commands/plan.mjs +13 -1
  42. package/src/commands/plugin.mjs +16 -1
  43. package/src/commands/prbody.mjs +45 -0
  44. package/src/commands/repoindex.mjs +3 -0
  45. package/src/commands/rules.mjs +145 -105
  46. package/src/commands/shared.mjs +36 -5
  47. package/src/commands/skills.mjs +293 -205
  48. package/src/commands/skillsinstall.mjs +100 -30
  49. package/src/commands/slice.mjs +353 -0
  50. package/src/commands/status.mjs +62 -0
  51. package/src/commands/sync.mjs +110 -15
  52. package/src/commands/undo.mjs +106 -0
  53. package/src/commands/upgrade.mjs +70 -8
  54. package/src/completions.mjs +191 -0
  55. package/src/diff.mjs +254 -0
  56. package/src/explain.mjs +258 -0
  57. package/src/federation/config.mjs +44 -0
  58. package/src/graph/load.mjs +14 -5
  59. package/src/hooks/dispatcher.mjs +110 -0
  60. package/src/hooks/gates.mjs +14 -3
  61. package/src/hooks/resolve.mjs +14 -0
  62. package/src/ignore.mjs +133 -0
  63. package/src/index/persist.mjs +66 -9
  64. package/src/journal.mjs +326 -0
  65. package/src/lease.mjs +113 -0
  66. package/src/mcp/server.mjs +15 -1
  67. package/src/mcp/tools.mjs +296 -256
  68. package/src/memory/decide.mjs +6 -1
  69. package/src/memory/lessons.mjs +12 -0
  70. package/src/output/index.mjs +82 -12
  71. package/src/package-version.mjs +49 -1
  72. package/src/packs/manage.mjs +13 -4
  73. package/src/plan/index.mjs +6 -0
  74. package/src/plugins.mjs +27 -6
  75. package/src/prbody.mjs +251 -0
  76. package/src/projection/manifest.mjs +13 -2
  77. package/src/projection/shared.mjs +4 -1
  78. package/src/secrets.mjs +231 -0
  79. package/src/status.mjs +152 -0
@@ -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,
@@ -87,11 +87,10 @@ async function loadPerSurfaceRules() {
87
87
 
88
88
  export async function listCommand(args, flags) {
89
89
  const root = flags.root ?? null;
90
- const { rules, broken } = await load(root);
91
-
92
90
  // With --root the question becomes "which of these apply to me", which is a
93
91
  // different answer from the catalogue and the one an agent usually wants.
94
- const selection = root ? await selectionFor(rules, root) : null;
92
+ const { rules, broken, notes } = await rulesCatalogue(root, { selectFor: root });
93
+ const selection = root ? new Set(rules.filter((r) => r.selected).map((r) => r.id)) : null;
95
94
 
96
95
  if (isJson(flags)) {
97
96
  emitJson(
@@ -99,19 +98,8 @@ export async function listCommand(args, flags) {
99
98
  ok: broken.length === 0,
100
99
  command: 'rules list',
101
100
  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 ?? [],
101
+ rules,
102
+ notes,
115
103
  },
116
104
  );
117
105
  return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
@@ -145,7 +133,7 @@ export async function listCommand(args, flags) {
145
133
  ];
146
134
 
147
135
  for (const rule of rows) {
148
- const mark = selection && selection.selected.has(rule.id) ? '*' : ' ';
136
+ const mark = selection && selection.has(rule.id) ? '*' : ' ';
149
137
  lines.push(
150
138
  render(mark, rule.id, kb(rule.bytes), rule.origin, appliesLabel(rule), rule.title ?? '(no title)'),
151
139
  );
@@ -156,7 +144,7 @@ export async function listCommand(args, flags) {
156
144
  }
157
145
  if (selection) {
158
146
  lines.push('', `* selected for ${root}`);
159
- for (const note of selection.notes) lines.push(` note ${note}`);
147
+ for (const note of notes) lines.push(` note ${note}`);
160
148
  }
161
149
  if (root) {
162
150
  const locals = rules.filter((r) => r.origin === 'local');
@@ -173,15 +161,128 @@ async function selectionFor(rules, root) {
173
161
  return { selected: new Set(selected.map((r) => r.id)), notes };
174
162
  }
175
163
 
164
+ // --- cores: one computation for the CLI and the MCP tools (Q15) ----------------
165
+ //
166
+ // `rules_list` and `rules_why` in the MCP surface rebuilt this catalogue from the
167
+ // same modules with narrower loads: the MCP list missed the per-surface rules and
168
+ // the MCP why failed on them, because neither shared the catalogue below. The
169
+ // commands render these facts for terminals; the tools return them as JSON. The
170
+ // CLI keeps its field names; the tools adopt them.
171
+ //
172
+ // A core throws when it cannot answer: the message names the problem, `remedy`
173
+ // names the next command, and `exitCode` is the exit the CLI leaves with.
174
+ function rulesFailure(message, remedy, exitCode) {
175
+ throw Object.assign(new Error(message), { remedy, exitCode });
176
+ }
177
+
178
+ async function loadDecisionsFor(root, all) {
179
+ const localDir = root ? join(root, 'docs', 'decisions') : null;
180
+ const hasLocal = localDir
181
+ ? (await readdir(localDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
182
+ : false;
183
+ return loadDecisions({
184
+ all,
185
+ ...(hasLocal ? { dir: localDir } : {}),
186
+ });
187
+ }
188
+
189
+ /** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
190
+ export async function rulesCatalogue(root, { selectFor = null } = {}) {
191
+ const { rules, broken } = await load(root);
192
+ const selection = selectFor ? await selectionFor(rules, selectFor) : null;
193
+ return {
194
+ rules: sorted(rules).map((r) => ({
195
+ id: r.id,
196
+ title: r.title,
197
+ origin: r.origin,
198
+ source: r.source,
199
+ applies: r.applies,
200
+ optIn: r.optIn ?? false,
201
+ fallback: r.fallback,
202
+ bytes: r.bytes,
203
+ error: r.error,
204
+ adrs: citations(r.body),
205
+ selected: selection ? selection.selected.has(r.id) : null,
206
+ })),
207
+ broken,
208
+ notes: selection?.notes ?? [],
209
+ };
210
+ }
211
+
212
+ /**
213
+ * The provenance facts for one rule or one decision record, in the shape the
214
+ * JSON documents carry. Text renders from the same facts; nothing re-derives.
215
+ */
216
+ export async function rulesWhyFacts(root, what, { all = false } = {}) {
217
+ const { rules } = await load(root);
218
+ if (/^ADR-\d{3,}$/i.test(what)) {
219
+ const id = what.toUpperCase();
220
+ const { ok, decisions, errors } = await loadDecisionsFor(root, all);
221
+ if (!ok) {
222
+ rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
223
+ }
224
+ const record = decisions.find((x) => x.id === id);
225
+ // Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
226
+ // tree, which resolves to nothing in the repo the reader is standing in. The
227
+ // remedy must be a command they can run.
228
+ if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules list`, EXIT.USAGE);
229
+ const replacement = record.supersededBy ? decisions.find((x) => x.id === record.supersededBy) : null;
230
+ return {
231
+ direction: 'decision',
232
+ id,
233
+ adr: record,
234
+ supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
235
+ inForce: replacement
236
+ ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
237
+ : { id: record.id, status: record.status, summary: record.summary },
238
+ rules: rules.filter((r) => citations(r.body).includes(id)).map((r) => r.id),
239
+ };
240
+ }
241
+ const rule = rules.find((r) => r.id === what);
242
+ if (!rule) {
243
+ const near = nearMatches(what, rules.map((r) => r.id));
244
+ rulesFailure(
245
+ `no rule with id "${what}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
246
+ `run: ${CLI} rules list`,
247
+ EXIT.USAGE,
248
+ );
249
+ }
250
+ const { ok, decisions, errors } = await loadDecisionsFor(root, all);
251
+ if (!ok) {
252
+ rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
253
+ }
254
+ const index = byId(decisions);
255
+ const cited = citations(rule.body);
256
+ const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
257
+ return {
258
+ direction: 'rule',
259
+ id: rule.id,
260
+ rule: rule.id,
261
+ title: rule.title ?? null,
262
+ source: rule.source,
263
+ origin: rule.origin,
264
+ adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
265
+ uncited: cited.length === 0,
266
+ others: others.map((r) => r.id),
267
+ };
268
+ }
269
+
176
270
  // --- show ---------------------------------------------------------------------
177
271
 
178
272
  export async function showCommand(args, flags) {
179
273
  const id = args[0];
180
274
  if (!id) return fail('rules show needs a rule id', EXIT.USAGE, `usage: ${CLI} rules show <id>`);
181
275
 
182
- const { rules, broken } = await load(flags.root ?? null);
276
+ const { rules } = await load(flags.root ?? null);
183
277
  const rule = rules.find((r) => r.id === id);
184
- if (!rule) return unknown(id, rules);
278
+ if (!rule) {
279
+ const near = nearMatches(id, rules.map((r) => r.id));
280
+ return fail(
281
+ `no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
282
+ EXIT.USAGE,
283
+ `run: ${CLI} rules list`,
284
+ );
285
+ }
185
286
 
186
287
  const adrs = citations(rule.body);
187
288
  if (isJson(flags)) {
@@ -218,47 +319,25 @@ export async function whyCommand(args, flags) {
218
319
  const what = args[0];
219
320
  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
321
 
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);
322
+ let facts;
323
+ try {
324
+ facts = await rulesWhyFacts(flags.root ?? null, what, { all: flags.all === true });
325
+ } catch (error) {
326
+ return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
327
+ }
243
328
 
244
329
  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
- );
330
+ emitJson({ ok: true, command: 'rules why', ...facts });
256
331
  return EXIT.OK;
257
332
  }
258
333
 
259
- const lines = [`${rule.id} - ${rule.title ?? '(no title)'}`, '', `source ${rule.source} (${rule.origin})`];
334
+ return facts.direction === 'decision' ? adrDirection(facts, flags) : ruleDirection(facts, flags);
335
+ }
336
+
337
+ function ruleDirection(facts, flags) {
338
+ const lines = [`${facts.rule} - ${facts.title ?? '(no title)'}`, '', `source ${facts.source} (${facts.origin})`];
260
339
 
261
- if (cited.length === 0) {
340
+ if (facts.uncited) {
262
341
  lines.push(
263
342
  '',
264
343
  'provenance NONE',
@@ -269,11 +348,10 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
269
348
  return EXIT.OK;
270
349
  }
271
350
 
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`);
351
+ lines.push('', `settled by ${facts.adrs.length} decision${facts.adrs.length === 1 ? '' : 's'}`);
352
+ for (const d of facts.adrs) {
353
+ if (d.missing) {
354
+ lines.push(` ${d.id} MISSING - the rule cites it, but this installation has no record of it`);
277
355
  continue;
278
356
  }
279
357
  lines.push(` ${d.id} ${d.title}`);
@@ -286,52 +364,23 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
286
364
 
287
365
  // The reverse edge is what makes this useful for maintaining the set: which
288
366
  // 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(', ')}`);
367
+ if (facts.others.length > 0) {
368
+ lines.push('', `shares a decision with ${facts.others.join(', ')}`);
292
369
  }
293
370
 
294
371
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
295
372
  return EXIT.OK;
296
373
  }
297
374
 
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
- }
375
+ function adrDirection(facts, flags) {
376
+ const d = facts.adr;
377
+ const restates = facts.rules;
329
378
 
330
379
  const lines = [
331
380
  `${d.id} - ${d.title}`,
332
381
  '',
333
382
  `status ${d.status}`,
334
- `restates ${restates.length > 0 ? restates.map((r) => r.id).join(', ') : '(no rule cites it)'}`,
383
+ `restates ${restates.length > 0 ? restates.join(', ') : '(no rule cites it)'}`,
335
384
  ];
336
385
  if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
337
386
  if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
@@ -360,7 +409,7 @@ async function adrDirection(adr, rules, decisions, flags) {
360
409
  // attached, and an agent that skims the ruling and not the banner acts on the
361
410
  // reversal. This is the failure the decision index's own header names: a provenance
362
411
  // feature that proves provenance but not currency.
363
- const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
412
+ const replacement = facts.replacement;
364
413
  const effective = replacement ?? d;
365
414
 
366
415
  if (replacement) {
@@ -413,13 +462,4 @@ async function adrDirection(adr, rules, decisions, flags) {
413
462
  // A superseded record is not a failure; it is the supersession rule working.
414
463
  // The caller asked a question and got a true answer.
415
464
  return EXIT.OK;
416
- }
417
-
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
- );
425
465
  }
@@ -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