simt-dev 0.6.2 → 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 (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -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
  }
@@ -0,0 +1,367 @@
1
+ // `simt doctor [--fix] [--yes] [--json]`
2
+ //
3
+ // Step 3 of the implementation sequence: classify a repo from marker files alone.
4
+ // Deliberately shipped early — it needs no registry, no rules and no projection,
5
+ // and it makes the tool useful before any content exists.
6
+ //
7
+ // Plan 0003 R47 adds the findings table and `--fix`. Every finding carries its
8
+ // remedy and whether that remedy is flagged safe, and `--fix` applies only the
9
+ // safe ones while listing the rest with the reason each needs a human:
10
+ //
11
+ // - safe means local, idempotent and journaled (the R49 journal is the undo).
12
+ // `sync`'s manifest repair, a hooks install over routing this tool already
13
+ // owns, and the journal's own .gitignore entry all qualify.
14
+ // - safe is necessary but not sufficient: the fix must also be a REPAIR, not
15
+ // an ADOPTION. `init` on a manifest-less repo is local, idempotent and
16
+ // journaled, and still flagged unsafe — adopting a repo chooses its rules,
17
+ // regions and CI, and the preview of that choice is what the operator must
18
+ // read before approving it. So is taking over hooks git did not route here:
19
+ // the bytes are journaled but the decision belongs to a human.
20
+ //
21
+ // Unsafe findings are never applied, under any flag combination. `--fix` without
22
+ // `--yes` previews: it names what it would apply and lists the rest.
23
+
24
+ import { join } from 'node:path';
25
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
26
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
27
+ import { fingerprint } from '../resolve/fingerprint.mjs';
28
+ import { resolveWorkspace } from '../workspace.mjs';
29
+ import { sharedWarning } from './shared.mjs';
30
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
31
+ import { manifestPath, readManifest, writeManifest } from '../projection/manifest.mjs';
32
+ import { planManifestRepair } from './sync.mjs';
33
+ import { provenanceDigest } from '../projection/project.mjs';
34
+ import { applyHooksInstall, plannedDiffs, planHooksInstall } from './hooks.mjs';
35
+ import { fileFor } from '../hooks/shim.mjs';
36
+ import { commitJournal, ignoreJournal, journalHygiene, journalIgnoreNext } from '../journal.mjs';
37
+
38
+ /**
39
+ * One config-health finding: what is wrong, how to fix it, and whether
40
+ * `doctor --fix` may apply that fix on its own.
41
+ *
42
+ * `apply`, when present, performs the repair into the caller's journal. Only
43
+ * findings with `safe: true` carry one; an unsafe finding is a report and a
44
+ * manual remedy, never a function waiting for a flag.
45
+ */
46
+ // `withDiffs` previews the safe fixes as `--diff` pairs (plan 0003 R50). Read at
47
+ // finding creation, before any fix is applied, so the before side is true in
48
+ // preview and in apply alike. Only safe findings carry pairs: an unsafe finding
49
+ // is never applied, so there is no write to preview.
50
+ async function collectFindings(root, { result, ws, shared, withDiffs = false }) {
51
+ const findings = [];
52
+ if (result.error) {
53
+ // Nothing else can be asked: classification failed, so every check built on
54
+ // it would be a guess. The run fails upstream; this entry is for the JSON.
55
+ findings.push({
56
+ id: 'fingerprint',
57
+ severity: 'error',
58
+ detail: result.error,
59
+ remedy: null,
60
+ why: null,
61
+ safe: false,
62
+ });
63
+ return findings;
64
+ }
65
+
66
+ const { manifest, exists, error: manifestError } = await readManifest(root);
67
+ if (manifestError !== null) {
68
+ // An unreadable manifest is a hand-edit, not a repair: re-rendering it
69
+ // would destroy whatever the other tool was saying.
70
+ findings.push({
71
+ id: 'manifest-unreadable',
72
+ severity: 'warning',
73
+ detail: `${MANIFEST_FILE}: ${manifestError}`,
74
+ remedy: `fix ${MANIFEST_FILE} by hand, or restore it from version control`,
75
+ why: 'only a human knows which bytes were intended',
76
+ safe: false,
77
+ });
78
+ } else if (!exists) {
79
+ // A repair restores intended state; an adoption chooses it. See the module
80
+ // header: `init` qualifies mechanically and is still listed, not applied.
81
+ findings.push({
82
+ id: 'manifest-absent',
83
+ severity: 'warning',
84
+ detail: `no ${MANIFEST_FILE}; this repository was never adopted`,
85
+ remedy: `run: ${CLI} init --root ${root} --yes`,
86
+ why: 'adoption chooses the rules; a fix only restores them',
87
+ safe: false,
88
+ });
89
+ } else {
90
+ const sha = await provenanceDigest();
91
+ const repair = await planManifestRepair(root, result.stack, sha); if (repair !== null) {
92
+ const staleBefore = withDiffs ? await readBefore(await manifestPath(root)) : null;
93
+ findings.push({
94
+ id: 'manifest-stale',
95
+ severity: 'warning',
96
+ detail: `generated field(s) ${repair.stale.join(', ')} do not match what this simt-dev writes`,
97
+ remedy: `run: ${CLI} sync --root ${root} --yes`,
98
+ why: null,
99
+ safe: true,
100
+ diffs: withDiffs ? [{ path: MANIFEST_FILE, before: staleBefore, after: repair.text }] : [],
101
+ apply: async (rt, journal) => {
102
+ await writeManifest(rt, repair.manifest, journal);
103
+ },
104
+ });
105
+ }
106
+
107
+ const planned = await planHooksInstall(root, { manifest, exists });
108
+ if (planned.takeover) {
109
+ const foreign = planned.state.foreign
110
+ .map((f) => `${f.event} runs ${f.path} (${f.bytes} bytes)`)
111
+ .join('; ');
112
+ findings.push({
113
+ id: 'hooks-takeover',
114
+ severity: 'warning',
115
+ detail:
116
+ foreign !== ''
117
+ ? `git runs hooks this tool did not write: ${foreign}`
118
+ : `git routes hooks at ${planned.current}, not at the hooks directory`,
119
+ remedy: `inspect the existing hooks, then run: ${CLI} hooks install --root ${root} --yes`,
120
+ why: 'replacing live hooks needs a human who read them first',
121
+ safe: false,
122
+ });
123
+ } else {
124
+ const bits = [...planned.state.problems];
125
+ for (const h of planned.obsolete) {
126
+ bits.push(`${fileFor(h.event)} enforces nothing and is still routed; reinstall removes it`);
127
+ }
128
+ const evidenceMismatch =
129
+ planned.evidenceDeclared && planned.evidenceSource !== planned.evidenceExisting;
130
+ if (evidenceMismatch) bits.push('commit-msg claim check differs from this simt-dev; reinstall refreshes it');
131
+ if (bits.length > 0) {
132
+ const hooksPairs = withDiffs ? await plannedDiffs(root, planned) : [];
133
+ findings.push({
134
+ id: 'hooks-incomplete',
135
+ severity: 'warning',
136
+ detail: bits.join('; '),
137
+ remedy: `run: ${CLI} hooks install --root ${root} --yes`,
138
+ why: null,
139
+ safe: true,
140
+ diffs: hooksPairs,
141
+ apply: async (rt, journal) => {
142
+ await applyHooksInstall(rt, planned, journal);
143
+ },
144
+ });
145
+ }
146
+ }
147
+
148
+ const hygiene = await journalHygiene(root);
149
+ if (!hygiene.ignored) {
150
+ const ignoreBefore = withDiffs ? await readBefore(join(root, '.gitignore')) : null;
151
+ const ignoreAfter = withDiffs ? journalIgnoreNext(ignoreBefore ?? '') : null;
152
+ findings.push({
153
+ id: 'journal-unignored',
154
+ severity: 'warning',
155
+ detail: `${hygiene.sets.length} journal set(s) on disk, but .gitignore does not ignore them; the next commit would share local undo state`,
156
+ remedy: 'ignore the journal directory (what --fix applies)',
157
+ why: null,
158
+ safe: true,
159
+ diffs: ignoreAfter === null ? [] : [{ path: '.gitignore', before: ignoreBefore, after: ignoreAfter }],
160
+ apply: async (rt, journal) => {
161
+ await ignoreJournal(rt, journal);
162
+ },
163
+ });
164
+ }
165
+ }
166
+
167
+ if (shared.declared && shared.state !== 'current') {
168
+ // The copy lives outside the repository (a reader's home directory), which
169
+ // R49 deliberately leaves unjournaled: `undo` must never reach past the
170
+ // repo root, so a fix with no undo path is listed, never applied.
171
+ findings.push({
172
+ id: 'shared-copy',
173
+ severity: 'warning',
174
+ detail: shared.detail,
175
+ remedy: shared.state === 'absent' || shared.state === 'stale' ? `run: ${CLI} shared install --yes` : null,
176
+ why: 'the copy lives outside this repository, past what undo can restore',
177
+ safe: false,
178
+ });
179
+ }
180
+
181
+ if (ws.error) {
182
+ findings.push({
183
+ id: 'workspace-config',
184
+ severity: 'error',
185
+ detail: `workspace config ${ws.detail}: ${ws.error}`,
186
+ remedy: 'fix the JSON, or remove the file',
187
+ why: 'only a human knows which workspace was intended',
188
+ safe: false,
189
+ });
190
+ }
191
+ return findings;
192
+ }
193
+
194
+ async function applySafeFixes(root, findings) {
195
+ const journal = [];
196
+ const fixed = [];
197
+ const failed = [];
198
+ for (const finding of findings) {
199
+ if (!finding.safe) continue;
200
+ try {
201
+ await finding.apply(root, journal);
202
+ finding.fixed = true;
203
+ fixed.push(finding.id);
204
+ } catch (error) {
205
+ failed.push({ id: finding.id, error: error.message ?? String(error) });
206
+ }
207
+ }
208
+ if (fixed.length > 0) await commitJournal(root, 'doctor --fix', journal);
209
+ return { applied: true, fixed, failed };
210
+ }
211
+
212
+ function findingLines(findings, { fixMode, applying }) {
213
+ return findings.flatMap((finding) => {
214
+ const head = `finding ${finding.id} ${finding.detail}`;
215
+ if (finding.fixed === true) return [head, ` fixed ${finding.remedy ?? 'applied'}`];
216
+ if (finding.remedy === null) return [head, ' manual: no command repairs this; read the detail above'];
217
+ if (!finding.safe) {
218
+ return [head, ` remedy: ${finding.remedy}`, ` manual: not applied by --fix (${finding.why})`];
219
+ }
220
+ if (!fixMode) return [head, ` remedy: ${finding.remedy}`, ' safe: apply with --fix --yes'];
221
+ if (!applying) return [head, ` remedy: ${finding.remedy}`, ' would fix; re-run with --yes to apply'];
222
+ return [head, ` remedy: ${finding.remedy}`];
223
+ });
224
+ }
225
+
226
+ // --- core: the config health both the CLI and the MCP `doctor` tool answer from (Q15)
227
+ //
228
+ // The MCP tool returned the fingerprint alone: stack, facts and manifest, and none
229
+ // of the findings the command reports. The command below renders these facts for
230
+ // terminals; the tool returns them as JSON. The CLI keeps its field names; the
231
+ // tool adopts them.
232
+ //
233
+ // See the module header: classification first, because everything else is a guess without it.
234
+ export async function diagnose(root, args = {}) {
235
+ const result = await fingerprint(root);
236
+ // Which source answered the workspace question, and where it looked. A tool
237
+ // that silently fell back to the wrong directory would make every downstream
238
+ // answer confidently wrong, so the answer is reported rather than inferred
239
+ // (ADR-014).
240
+ const ws = await resolveWorkspace(args);
241
+ // The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
242
+ // because it is the one thing about a repository that is true of the reader's machine
243
+ // rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
244
+ // that can notice a region that dropped rules into a file nobody has. Reported, never
245
+ // blocking — the region is correct either way, and what is wrong is the reader's machine.
246
+ const shared = await sharedWarning(root, result.manifest ?? null, args);
247
+
248
+ const withDiffs = args.fix === true && args.diff === true;
249
+ const findings = await collectFindings(root, { result, ws, shared, withDiffs });
250
+ // Under `--json` the document is the whole report, so it has to carry both
251
+ // failures the text path reports AND the workspace answer the text prints.
252
+ const failure = result.error
253
+ ? { error: `${root}: ${result.error}`, remedy: null }
254
+ : ws.error
255
+ ? {
256
+ error: `workspace config ${ws.detail}: ${ws.error}`,
257
+ remedy: 'fix the JSON, or remove the file',
258
+ }
259
+ : null;
260
+ const code = failure ? EXIT.CONFIG : EXIT.OK;
261
+ return { result, ws, shared, findings, failure, code };
262
+ }
263
+
264
+ /** The findings as JSON-safe data: drops the `apply` closures only `--fix` uses. */
265
+ export function serialisedFindings(findings) {
266
+ return findings.map(({ id, severity, detail, remedy, why, safe, fixed }) => ({
267
+ id,
268
+ severity,
269
+ detail,
270
+ remedy,
271
+ // Null for safe findings: there is no human needed to explain.
272
+ why: why ?? null,
273
+ safe,
274
+ fixed: fixed === true,
275
+ }));
276
+ }
277
+
278
+ export async function runDoctor(positional, flags) {
279
+ const root = flags.root ?? positional[0] ?? process.cwd();
280
+ const { result, ws, shared, findings, failure, code: diagnosisCode } = await diagnose(root, flags);
281
+
282
+ const fixMode = flags.fix === true;
283
+ const mode = writeMode(flags);
284
+ let fix = { requested: fixMode, applied: false, fixed: [], failed: [] };
285
+ if (fixMode && mode.apply) {
286
+ fix = { requested: true, ...(await applySafeFixes(root, findings)) };
287
+ }
288
+ const failedFix = fix.failed.length > 0;
289
+
290
+ // A scripted consumer has only this document to go on, so `ok` is derived from
291
+ // the exit code rather than written as a constant (S10). A failed fix is a
292
+ // failure of this run on top of the diagnosis.
293
+ const code = failedFix ? EXIT.FAIL : diagnosisCode;
294
+
295
+ // The findings travel in both modes: a script reads the array, a terminal
296
+ // reads the lines, and neither may learn about a different set of problems.
297
+ const serialised = serialisedFindings(findings);
298
+
299
+ if (isJson(flags)) {
300
+ emitJson({
301
+ ok: code === EXIT.OK,
302
+ command: 'doctor',
303
+ root,
304
+ ...result,
305
+ // The workspace answer is a fact about which directory the tool would
306
+ // act on next (ADR-014). It is printed in the text report; leaving it
307
+ // out of the JSON meant a machine-readable caller could not see it at
308
+ // all, which is the one-question-two-answers defect again.
309
+ workspace: {
310
+ root: ws.root ?? null,
311
+ source: ws.source,
312
+ detail: ws.detail,
313
+ error: ws.error ?? null,
314
+ },
315
+ shared,
316
+ findings: serialised,
317
+ fix,
318
+ ...diffField(fixMode && flags.diff === true ? findings.flatMap((f) => f.diffs ?? []) : [], flags),
319
+ ...(failure ?? {}),
320
+ });
321
+ return code;
322
+ }
323
+ if (result.error) {
324
+ return fail(`${root}: ${result.error}`, EXIT.CONFIG);
325
+ }
326
+ emit(
327
+ [
328
+ `root ${root}`,
329
+ `stack ${result.stack.length > 0 ? result.stack.join(', ') : '(undetected)'}`,
330
+ `evidence ${result.evidence.length > 0 ? result.evidence.join(' ') : '(none)'}`,
331
+ // Facts are what a profile selects on when it wants to survive the
332
+ // framework being replaced, so they are reported with the path that proved
333
+ // each one rather than as a bare list (ADR-013).
334
+ `facts ${
335
+ result.facts.length > 0
336
+ ? result.facts.map((f) => `${f}(${result.factEvidence[f] ?? '?'})`).join(' ')
337
+ : '(none)'
338
+ }`,
339
+ `manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
340
+ `workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
341
+ // Printed only when this repository declares shared rules to a workspace copy: a line in
342
+ // every `doctor` is a line in every reader's terminal, and the reader who has
343
+ // nothing to install is not served by one (ADR-084).
344
+ ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
345
+ ...findingLines(findings, { fixMode, applying: mode.apply }),
346
+ ].join('\n'),
347
+ { maxBytes: flags.maxBytes ?? 0 },
348
+ );
349
+ // After the report: the report says what, the diff shows it (R50). Only the
350
+ // fix plan is diffed, so without --fix there is nothing to show.
351
+ if (fixMode && flags.diff === true) emitDiffs(findings.flatMap((f) => f.diffs ?? []), flags);
352
+ if (failedFix) {
353
+ return fail(
354
+ `doctor --fix applied ${fix.fixed.length} remedie(s) and ${fix.failed.length} failed: ${fix.failed.map((f) => `${f.id} (${f.error})`).join('; ')}`,
355
+ EXIT.FAIL,
356
+ );
357
+ }
358
+ // An unconfigured workspace is a fact, not a failure: a single repo with no
359
+ // workspace around it is a normal, supported case. A config file that EXISTS and
360
+ // does not parse is a real mistake, and that does fail - with the parse error,
361
+ // because naming the file without saying what is wrong with it sends the reader
362
+ // to the file and gives them nothing to look for.
363
+ if (ws.error) {
364
+ return fail(`workspace config ${ws.detail}: ${ws.error}`, EXIT.CONFIG, 'fix the JSON, or remove the file');
365
+ }
366
+ return EXIT.OK;
367
+ }
@@ -0,0 +1,52 @@
1
+ // simt explain <CODE> [--json]
2
+ //
3
+ // The last mile of the contract work is a reader: every status `simt check`
4
+ // can emit gets a plain-language entry — what it means, why the gate reports
5
+ // it, how to fix it, and a concrete trigger — either as text or as the same
6
+ // JSON envelope every command answers in. The lookup is case-insensitive and
7
+ // folds the `-`/`_` split, so DRIFT, Drift and drift are one question; an
8
+ // unknown code is a usage error that lists the known codes rather than a guess.
9
+
10
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
11
+ import { EXPLAIN_CODES } from '../explain.mjs';
12
+
13
+ export const EXPLAIN_ALIASES = { drifted: 'drift' };
14
+
15
+ function fold(raw) {
16
+ const folded = String(raw ?? '').toLowerCase().replace(/_/g, '-');
17
+ return EXPLAIN_ALIASES[folded] ?? folded;
18
+ }
19
+
20
+ export function listExplainCodes() {
21
+ return Object.keys(EXPLAIN_CODES).sort();
22
+ }
23
+
24
+ export async function explainCommand(positional, flags) {
25
+ const raw = positional[0];
26
+ if (!raw) {
27
+ return fail(
28
+ 'explain needs a code',
29
+ EXIT.USAGE,
30
+ `one of: ${listExplainCodes().join(', ')}`,
31
+ );
32
+ }
33
+ const code = fold(raw);
34
+ const row = EXPLAIN_CODES[code];
35
+ if (!row) {
36
+ return fail(
37
+ `unknown finding code \`${raw}\``,
38
+ EXIT.USAGE,
39
+ `one of: ${listExplainCodes().join(', ')}`,
40
+ );
41
+ }
42
+
43
+ if (isJson(flags)) {
44
+ emitJson({ ok: true, command: 'explain', code, ...row });
45
+ return EXIT.OK;
46
+ }
47
+ emit(
48
+ [`code ${code}`, '', row.what, '', `Why: ${row.why}`, '', `Fix: ${row.fix}`, '', `Example: ${row.example}`].join('\n'),
49
+ { maxBytes: flags.maxBytes ?? 0 },
50
+ );
51
+ return EXIT.OK;
52
+ }