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
package/src/argv.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // Anything unrecognised is an error, not a silent ignore: a typo'd gate name
11
11
  // that is ignored is worse than a typo'd gate name that fails.
12
12
 
13
- import { EXIT, fail } from './output/index.mjs';
13
+ import { EXIT } from './output/index.mjs';
14
14
 
15
15
  // `--quiet`, `--semantic` and `--mermaid` were here and were read by nothing (review
16
16
  // 0001 filed it; it survived every change to this file since). Each was a plan's
@@ -93,6 +93,18 @@ export const BOOL_FLAGS = new Set([
93
93
  // the deterministic outline. Off by default: no model in the default path (P1),
94
94
  // and a model that is not asked for should not be opened against (ADR-090).
95
95
  'local',
96
+ // doctor: apply the remedies flagged safe (local, idempotent, journaled) and
97
+ // list the rest. Off by default because a repair, however safe, is still a
98
+ // write, and a command whose name is a diagnosis should not write by default.
99
+ 'fix',
100
+ // status: print the one-line gate summary from the last check. Off by
101
+ // default because the only mode this command has is the line; bare `status`
102
+ // is a usage error naming it, the way `plan` without a subcommand is.
103
+ 'line',
104
+ // every writer: print the plan as unified diffs. Off by default because a
105
+ // plan report is the compact answer and the diff is the verbose one; --diff
106
+ // never changes what the run writes or its exit code, only what it shows.
107
+ 'diff',
96
108
  ]);
97
109
 
98
110
  export const VALUE_FLAGS = new Set([
@@ -138,6 +150,15 @@ export const VALUE_FLAGS = new Set([
138
150
  // plan done: the test command that proves the slice. Plan 0003 R31.
139
151
  'test',
140
152
  'state',
153
+ // slice finish: the Conventional Commits type for the commit it makes. A value,
154
+ // not a boolean, because a row title cannot say whether the slice was a feat, a
155
+ // fix or a chore, and deriving it from the words would be a guess no reader of
156
+ // the message could argue with (plan 0003 R53).
157
+ 'type',
158
+ // lease: how long the claim lasts, in seconds. A value, because "a lease is short"
159
+ // is not a number any reader can act on, and an expiry nobody chose is an expiry
160
+ // nobody can plan around (plan 0003 R54, ADR-101).
161
+ 'ttl',
141
162
  // map: outline (paths), names (exported symbols) or signatures (the line).
142
163
  // A boolean would not say which of the three the caller wanted.
143
164
  'level',
@@ -151,6 +172,10 @@ export const VALUE_FLAGS = new Set([
151
172
  // "generate a package" with no destination would have to guess, and a guess about where
152
173
  // a directory tree goes is the one failure that leaves files behind.
153
174
  'out',
175
+ // pr-body: the ref the body diffs against. A value, because the fork point is a
176
+ // name only the author's branch knows, and the default (origin/main, then main)
177
+ // is a guess the report states out loud rather than a question it asks.
178
+ 'base',
154
179
  ]);
155
180
 
156
181
  /**
@@ -168,9 +193,29 @@ export const VALUE_FLAGS = new Set([
168
193
  */
169
194
  export const REPEATABLE_FLAGS = new Set(['skill', 'arm', 'compare', 'supersedes']);
170
195
 
196
+ /**
197
+ * Whether a raw argv asks for JSON, read before parsing succeeds.
198
+ *
199
+ * A parse failure is still a result the caller may need as JSON (Q11), but the
200
+ * parsed flags do not exist yet — so the dispatcher reads the spelling straight
201
+ * off argv. Only the exact `--json` spelling counts, and nothing past `--` does:
202
+ * a positional that happens to read `--json` is a path, not a flag.
203
+ */
204
+ export function rawJsonFlag(argv) {
205
+ for (const arg of argv) {
206
+ if (arg === '--') break;
207
+ if (arg === '--json') return true;
208
+ if (arg === '--no-json') return false;
209
+ }
210
+ return false;
211
+ }
212
+
171
213
  /**
172
214
  * @param {string[]} argv raw arguments, excluding node and the script path
173
- * @returns {{ok: true, value: object} | {ok: false, code: number}}
215
+ * @returns {{ok: true, value: object} | {ok: false, error: {message: string, code: number}}}
216
+ *
217
+ * A failure is data, never output: reporting it is the caller's job, because only
218
+ * the caller knows whether `--json` was asked for (Q11). Nothing here prints.
174
219
  */
175
220
  export function parse(argv) {
176
221
  const flags = {};
@@ -196,14 +241,14 @@ export function parse(argv) {
196
241
  if (raw.startsWith('no-')) {
197
242
  const positive = raw.slice(3);
198
243
  if (!BOOL_FLAGS.has(positive)) {
199
- return { ok: false, code: usage(`unknown flag --${raw}`) };
244
+ return { ok: false, error: usage(`unknown flag --${raw}`) };
200
245
  }
201
246
  flags[camel(positive)] = false;
202
247
  continue;
203
248
  }
204
249
  if (BOOL_FLAGS.has(raw)) {
205
250
  if (inlineValue !== undefined) {
206
- return { ok: false, code: usage(`--${raw} takes no value`) };
251
+ return { ok: false, error: usage(`--${raw} takes no value`) };
207
252
  }
208
253
  flags[camel(raw)] = true;
209
254
  continue;
@@ -211,19 +256,19 @@ export function parse(argv) {
211
256
  if (VALUE_FLAGS.has(raw)) {
212
257
  const value = inlineValue ?? argv[i + 1];
213
258
  if (value === undefined || (inlineValue === undefined && value.startsWith('--'))) {
214
- return { ok: false, code: usage(`--${raw} requires a value`) };
259
+ return { ok: false, error: usage(`--${raw} requires a value`) };
215
260
  }
216
261
  if (inlineValue === undefined) i += 1;
217
262
  if (raw === 'budget' || raw === 'max-bytes') {
218
263
  const n = Number(value);
219
264
  if (!Number.isInteger(n) || n < 0) {
220
- return { ok: false, code: usage(`--${raw} must be a non-negative integer, received ${value}`) };
265
+ return { ok: false, error: usage(`--${raw} must be a non-negative integer, received ${value}`) };
221
266
  }
222
267
  flags[camel(raw)] = n;
223
268
  } else if (raw === 'reps') {
224
269
  const n = Number(value);
225
270
  if (!Number.isInteger(n) || n < 1) {
226
- return { ok: false, code: usage(`--reps must be a positive integer, received ${value}`) };
271
+ return { ok: false, error: usage(`--reps must be a positive integer, received ${value}`) };
227
272
  }
228
273
  flags.reps = n;
229
274
  } else {
@@ -234,7 +279,7 @@ export function parse(argv) {
234
279
  }
235
280
  continue;
236
281
  }
237
- return { ok: false, code: usage(`unknown flag --${raw}`) };
282
+ return { ok: false, error: usage(`unknown flag --${raw}`) };
238
283
  }
239
284
 
240
285
  return { ok: true, value: { flags, positional } };
@@ -253,5 +298,5 @@ function camel(name) {
253
298
  }
254
299
 
255
300
  function usage(message) {
256
- return fail(message, EXIT.USAGE);
301
+ return { message, code: EXIT.USAGE };
257
302
  }
@@ -128,13 +128,15 @@ function header(own) {
128
128
  * is the release a reader recognises, and Dependabot's `github-actions` ecosystem
129
129
  * rewrites the pair together - which is why the pin needs no maintenance here.
130
130
  *
131
- * The major version is deliberately the one the templates already used rather than the
132
- * newest available. `checkout` v7 and `setup-node` v7 both carry breaking changes, and
133
- * a gate whose behaviour changes because somebody re-ran an updater is precisely what
134
- * pinning exists to prevent. Moving a major is one reviewed line in this table.
131
+ * The major version moves by review, not by updater. `checkout` stayed on v4 while v7
132
+ * carried breaking changes a gate should not absorb by re-running Dependabot; it moved
133
+ * to v7.0.1 when the v7 line had run green in this repository's own workflows (the
134
+ * checkout steps of the v7 Dependabot run all succeeded — only the template-drift
135
+ * tests failed, which is the pin doing its job). `setup-node` stays on v4: no run has
136
+ * yet proven a newer major here, and moving it is the same one reviewed line.
135
137
  */
136
138
  export const ACTIONS = {
137
- checkout: { repo: 'actions/checkout', commit: '11d5960a326750d5838078e36cf38b85af677262', release: 'v4.4.0' },
139
+ checkout: { repo: 'actions/checkout', commit: '3d3c42e5aac5ba805825da76410c181273ba90b1', release: 'v7.0.1' },
138
140
  setupNode: { repo: 'actions/setup-node', commit: '49933ea5288caeca8642d1e84afbd3f7d6820020', release: 'v4.4.0' },
139
141
  };
140
142
 
@@ -22,8 +22,10 @@
22
22
  // tool call. Failures of this command exit 1, which a harness reports as a failed hook,
23
23
  // and that is the right blast radius for a hook that is broken rather than refusing.
24
24
 
25
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
+ import { mkdir, readFile } from 'node:fs/promises';
26
26
  import { dirname, join } from 'node:path';
27
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
28
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
27
29
 
28
30
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
29
31
  import { CLI } from '../name.mjs';
@@ -185,6 +187,17 @@ async function installHooks(root, flags) {
185
187
 
186
188
  const refused = plan.filter((p) => p.error);
187
189
 
190
+ // The `--diff` pairs, read before anything is written so the before side is
191
+ // true in preview and in apply alike (R50).
192
+ const diffs =
193
+ flags.diff === true
194
+ ? await Promise.all(
195
+ plan
196
+ .filter((p) => p.action !== 'unchanged')
197
+ .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text })),
198
+ )
199
+ : [];
200
+
188
201
  if (!mode.apply) {
189
202
  if (isJson(flags)) {
190
203
  emitJson({
@@ -195,6 +208,7 @@ async function installHooks(root, flags) {
195
208
  plan: plan.map(({ text, ...rest }) => rest),
196
209
  notWritten: others,
197
210
  filesWritten: false,
211
+ ...diffField(diffs, flags),
198
212
  });
199
213
  return refused.length > 0 ? EXIT.FAIL : EXIT.OK;
200
214
  }
@@ -207,10 +221,12 @@ async function installHooks(root, flags) {
207
221
  '',
208
222
  refused.length > 0
209
223
  ? `REFUSED ${refused.length} config file(s) could not be read; nothing written`
210
- : `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
224
+ : `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
211
225
  ].join('\n'),
212
226
  { maxBytes: flags.maxBytes ?? 0 },
213
227
  );
228
+ // After the report: the report says what, the diff shows it (R50).
229
+ emitDiffs(diffs, flags);
214
230
  return refused.length > 0 ? EXIT.FAIL : EXIT.OK;
215
231
  }
216
232
 
@@ -221,12 +237,15 @@ async function installHooks(root, flags) {
221
237
  return fail(`refused to write ${refused.map((p) => `${p.path} (${p.error})`).join('; ')}`, EXIT.FAIL);
222
238
  }
223
239
 
240
+ // Journaled so `undo` restores this run (R49).
241
+ const journal = [];
224
242
  const written = [];
225
243
  for (const p of plan.filter((x) => x.action !== 'unchanged')) {
226
244
  await mkdir(join(root, dirname(p.path)), { recursive: true });
227
- await writeFile(join(root, p.path), p.text);
245
+ await journaledWriteFile(root, journal, join(root, p.path), p.text);
228
246
  written.push(p);
229
247
  }
248
+ await commitJournal(root, 'agent-hooks install', journal);
230
249
 
231
250
  const notes = [];
232
251
  for (const p of written) {
@@ -245,6 +264,7 @@ async function installHooks(root, flags) {
245
264
  written: written.map((p) => p.path),
246
265
  filesWritten: true,
247
266
  notes,
267
+ ...diffField(diffs, flags),
248
268
  });
249
269
  return EXIT.OK;
250
270
  }
@@ -259,6 +279,8 @@ async function installHooks(root, flags) {
259
279
  ].join('\n'),
260
280
  { maxBytes: flags.maxBytes ?? 0 },
261
281
  );
282
+ // After the report: the report says what, the diff shows it (R50).
283
+ emitDiffs(diffs, flags);
262
284
  for (const o of others) note(`note ${o.id}: not written - ${o.note}`);
263
285
  for (const n of notes) note(`note ${n}`);
264
286
  note(`note never committed or pushed (ADR-001); review, then commit yourself`);
@@ -18,8 +18,10 @@
18
18
  // commands owns which file would be learning about ownership twice to learn nothing
19
19
  // new, and the next .github file would arrive with a third command to remember.
20
20
 
21
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
+ import { mkdir, readFile } from 'node:fs/promises';
22
22
  import { dirname } from 'node:path';
23
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
24
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
23
25
 
24
26
  import { EXIT, emit, emitJson, isJson, note, writeMode } from '../output/index.mjs';
25
27
  import { planWorkflow, renderWorkflow, WORKFLOW_FILE } from '../ci/workflow.mjs';
@@ -121,18 +123,37 @@ export async function ciCommand(positional, flags) {
121
123
  return EXIT.FAIL;
122
124
  }
123
125
 
126
+ // The `--diff` pairs, read before anything is written so the before side is
127
+ // true in preview and in apply alike (R50).
128
+ const renders = new Map(targets().map(({ key, render }) => [key, render]));
129
+ const diffs =
130
+ flags.diff === true
131
+ ? await Promise.all(
132
+ plans
133
+ .filter((p) => p.action !== 'unchanged')
134
+ .map(async (p) => ({ path: p.file, before: await readBefore(p.path), after: renders.get(p.key)(p) })),
135
+ )
136
+ : [];
137
+
124
138
  if (isJson(flags)) {
125
- emitJson({ ok: true, command: 'ci', root, files: todo.map(publicPlan), written: false, dryRun: !mode.apply }, {});
139
+ emitJson(
140
+ { ok: true, command: 'ci', root, files: todo.map(publicPlan), written: false, dryRun: !mode.apply, ...diffField(diffs, flags) },
141
+ {},
142
+ );
126
143
  } else {
127
144
  emit(lines.join('\n'));
128
145
  }
129
146
 
130
147
  if (!mode.apply) {
148
+ // After the report: the report says what, the diff shows it (R50).
149
+ emitDiffs(diffs, flags);
131
150
  note('');
132
151
  note(mode.dryRun ? 'dry-run nothing written' : `dry-run nothing written; apply with: ${CLI} ci --yes`);
133
152
  return EXIT.OK;
134
153
  }
135
154
 
155
+ // Journaled so `undo` restores this run (R49).
156
+ const journal = [];
136
157
  for (const { key, plan, render } of targets()) {
137
158
  const p = plans.find((x) => x.key === key);
138
159
  if (p.action === 'unchanged') continue;
@@ -140,9 +161,10 @@ export async function ciCommand(positional, flags) {
140
161
  // The renderer takes the plan because detection-driven content is a function of
141
162
  // this repo's facts: a dependabot config rendered without them would watch
142
163
  // nothing, which is the same silent-nothing the detection ruling exists to stop.
143
- await writeFile(p.path, render(p));
164
+ await journaledWriteFile(root, journal, p.path, render(p));
144
165
  note(`wrote ${p.file} (${p.action}; the template and this copy now agree)`);
145
166
  }
167
+ await commitJournal(root, 'ci', journal);
146
168
  if (isJson(flags)) {
147
169
  emitJson(
148
170
  {
@@ -151,10 +173,13 @@ export async function ciCommand(positional, flags) {
151
173
  root,
152
174
  files: plans.map((p) => ({ file: p.file, action: p.action, bytes: p.bytes })),
153
175
  written: true,
176
+ ...diffField(diffs, flags),
154
177
  },
155
178
  {},
156
179
  );
157
180
  }
181
+ // After the report: the report says what, the diff shows it (R50).
182
+ emitDiffs(diffs, flags);
158
183
  return EXIT.OK;
159
184
  }
160
185
 
@@ -0,0 +1,27 @@
1
+ // `simt completions <shell>` — print a completion script (plan 0003 R51).
2
+ //
3
+ // The script goes to stdout and nothing else does, because the install is a
4
+ // redirect: `source <(simt completions bash)`, or the zsh file dropped onto
5
+ // `$fpath`. A report line above it would be a report line in the user's shell
6
+ // config.
7
+
8
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
+ import { SHELLS, renderCompletions } from '../completions.mjs';
10
+
11
+ export async function completionsCommand(args, flags, commands) {
12
+ const shell = args[0];
13
+ if (shell === undefined) {
14
+ return fail('completions needs a shell', EXIT.USAGE, `one of: ${SHELLS.join(', ')}`);
15
+ }
16
+ if (!SHELLS.includes(shell)) {
17
+ return fail(`unknown shell \`${shell}\``, EXIT.USAGE, `one of: ${SHELLS.join(', ')}`);
18
+ }
19
+
20
+ const script = renderCompletions(shell, commands);
21
+ if (isJson(flags)) {
22
+ emitJson({ ok: true, command: 'completions', shell, script });
23
+ return EXIT.OK;
24
+ }
25
+ emit(script, { maxBytes: flags.maxBytes ?? 0 });
26
+ return EXIT.OK;
27
+ }
@@ -7,6 +7,8 @@ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/i
7
7
  import { CLI } from '../name.mjs';
8
8
  import { DECISIONS_DIR } from '../decisions/index.mjs';
9
9
  import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
+ import { commitJournal } from '../journal.mjs';
11
+ import { diffField, emitDiffs } from '../diff.mjs';
10
12
  import { parsePathList } from '../argv.mjs';
11
13
 
12
14
  export async function decideCommand(args, flags) {
@@ -49,12 +51,17 @@ export async function decideCommand(args, flags) {
49
51
  const parsedPaths = parsePathList(flags.paths);
50
52
  const explicitPaths = flags.paths !== undefined ? parsedPaths : undefined;
51
53
 
54
+ // Journaled so `undo` restores the scaffold (R49); committed only when the
55
+ // run actually wrote, which commitJournal already guarantees.
56
+ const journal = [];
52
57
  const res = await scaffoldDecision(root, {
53
58
  title,
54
59
  affects: explicitPaths,
55
60
  supersedes: flags.supersedes,
56
61
  write: mode.apply,
62
+ journal: mode.apply ? journal : undefined,
57
63
  });
64
+ if (mode.apply && res.ok) await commitJournal(root, 'decide', journal);
58
65
 
59
66
  if (!res.ok) {
60
67
  return fail(
@@ -70,6 +77,10 @@ export async function decideCommand(args, flags) {
70
77
  note('dry-run no files written');
71
78
  }
72
79
 
80
+ // A scaffold always creates: the number is free by construction, so the
81
+ // before side is empty and the pair is the whole file (R50).
82
+ const diffs = flags.diff === true ? [{ path: res.relPath, before: null, after: res.text }] : [];
83
+
73
84
  if (isJson(flags)) {
74
85
  emitJson({
75
86
  ok: true,
@@ -84,10 +95,10 @@ export async function decideCommand(args, flags) {
84
95
  supersedes: res.supersedes,
85
96
  written: res.written,
86
97
  dryRun: !mode.apply,
98
+ ...diffField(diffs, flags),
87
99
  });
88
100
  return EXIT.OK;
89
101
  }
90
-
91
102
  const supersedesLabel =
92
103
  res.supersedes.length > 0
93
104
  ? res.supersedes
@@ -112,5 +123,7 @@ export async function decideCommand(args, flags) {
112
123
  ];
113
124
 
114
125
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
126
+ // After the report: the report says what, the diff shows it (R50).
127
+ emitDiffs(diffs, flags);
115
128
  return EXIT.OK;
116
129
  }