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
@@ -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
+ }
@@ -1,13 +1,18 @@
1
- import { emit } from '../output/index.mjs';
2
- import { parse } from '../argv.mjs';
3
- import { CLI, MANIFEST_FILE } from '../name.mjs';
1
+ import { emit, fail, jsonFailMode } from '../output/index.mjs';
2
+ import { parse, rawJsonFlag } from '../argv.mjs';
3
+ import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
4
+ import { SESSION_ENV } from '../agenthooks/policy.mjs';
4
5
  import { DEFAULT_DIRECTORIES, DEFAULT_TEMPLATE } from '../plan/template.mjs';
5
6
 
6
7
  const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
7
8
  (dry-run unless --yes; conflicts block without
8
9
  --accept-conflicts; in place unless --backup;
9
10
  never commits or pushes)
10
- ${CLI} doctor classify a repo and check config health
11
+ ${CLI} doctor [--fix] classify a repo and check config health (with --fix:
12
+ apply the remedies flagged safe and list the rest;
13
+ dry-run unless --yes)
14
+ ${CLI} status --line one line from the last check, for prompts and tmux
15
+ (as of the last check; STALE after new commits)
11
16
  ${CLI} check the gate: projection drift, manifest validity, policy
12
17
  ${CLI} sync [--yes] regenerate the projection (idempotent)
13
18
  (prints the plan and exits 1 on drift without --yes,
@@ -58,20 +63,41 @@ ${CLI} agent-hooks install write this repo's policies into the harnesses tha
58
63
  key it does not own; Codex needs the hook trusted)
59
64
  ${CLI} agent-hooks status what each harness will run, and what is not trusted yet
60
65
  ${CLI} plugin <claude|gemini|cursor> generate a harness package from the same tables
61
- (dry-run unless --yes; writes a self-contained runtime, the
62
- hooks and MCP registration, and for cursor the kernel rules;
63
- the skill registry is workspace data and is not shipped)
66
+ (dry-run unless --yes; writes a self-contained runtime, the
67
+ hooks and MCP registration, and for cursor the kernel rules;
68
+ the skill registry is workspace data and is not shipped)
69
+ ${CLI} completions <shell> print a bash, zsh or fish completion script
70
+ (generated from the command registry, so every command
71
+ completes; deterministic bytes; stdout only - redirect
72
+ or source it)
64
73
  ${CLI} plan status count tracker rows
65
74
  (${MANIFEST_FILE} key plans when set, otherwise
66
75
  the ${DEFAULT_TEMPLATE} template:
67
76
  ${DEFAULT_DIRECTORIES.join(' and ')})
68
77
  ${CLI} plan next [<id>] one ready slice's section, or the one named; ~1 KB
69
78
  ${CLI} plan done <id> [<sha>] mark one row; dry-run unless --yes
79
+ ${CLI} slice start <id> give a slice its own worktree and branch, preload the
80
+ brief, and mark the row In progress (dry-run unless
81
+ --yes; the worktree is ${STATE_DIR}/worktrees/<id> on
82
+ branch slice/<id>, the brief ${STATE_DIR}/brief.md in it)
83
+ ${CLI} slice finish <id> run the pre-commit gates, commit what is staged with a
84
+ Conventional subject from the row's title, then record
85
+ the SHA and the --test command on the row (dry-run
86
+ unless --yes; --type picks the type, default feat)
87
+ ${CLI} lease [<paths>...] claim files for one session and a TTL, or list the
88
+ claims (dry-run unless --yes; the session is --session
89
+ <id> or ${SESSION_ENV}; a pre-edit hook refuses an edit
90
+ to a path another session holds — ADR-101)
70
91
  ${CLI} graph lint check the skill registry against the skills on disk
71
92
  (byte counts, dangling edges, unresolvable variants)
72
93
  ${CLI} ci simt-dev's own .github files: create or refresh them
73
94
  (dry-run unless --yes; refreshes the templates' own copies
74
95
  and no repository else's)
96
+ ${CLI} ignore [--yes] render the one .simtignore list into .cursorignore,
97
+ .aiderignore, .geminiignore and Claude's
98
+ permissions.deny Read(...) entries (dry-run unless
99
+ --yes; --diff prints the plan as unified diffs; the
100
+ Claude settings file is merged, never replaced)
75
101
  ${CLI} compare org-wide matrix across every sibling repo
76
102
  (read-only; exits non-zero on defects, not on
77
103
  repos that have not adopted yet)
@@ -93,6 +119,11 @@ ${CLI} map [--task "<text>"] [--level outline|names|signatures]
93
119
  --budget bytes (default 2048; 0 is unlimited). outline
94
120
  is paths, names adds exports, signatures adds the line.
95
121
  --recall reports gold-file recall@10 on mined tasks
122
+ ${CLI} pr-body [--base <ref>] a pull request body: the evidence block for this tree,
123
+ the decision records the diff touches, the gate
124
+ state from the last check, and the residual risk
125
+ in ADR-008 vocabulary (read-only; the exit code
126
+ never carries the verdict)
96
127
  ${CLI} evidence the runs recorded against the tree on disk
97
128
  ${CLI} evidence check <file> a commit message that claims a run this tree does not
98
129
  have: a warning, or a block when gates.evidence is "block"
@@ -100,10 +131,17 @@ ${CLI} lessons list deterministic lesson candidates in .simt/lessons.
100
131
  and promoted lessons in docs/lessons/*.md
101
132
  ${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
102
133
  (dry-run unless --yes; writes Appendix A frontmatter)
134
+ ${CLI} explain <CODE> what a check finding means: the decision behind it, how
135
+ to fix it, and a concrete trigger (DRIFT, UNMANAGED,
136
+ PACK-DIGEST-MISMATCH, ...; case-insensitive)
103
137
  ${CLI} decide [<title>] scaffold an ADR with the next free number, a Status: line,
104
138
  a rulings skeleton, and structured simt-affects +
105
139
  simt-supersedes frontmatter (dry-run unless --yes;
106
140
  --check lints ADRs for prose-only partial supersessions)
141
+ ${CLI} undo restore the last journaled set of writes, but only
142
+ when every file in it is untouched since (dry-run
143
+ unless --yes; a touched file refuses the whole undo
144
+ and nothing is restored)
107
145
  ${CLI} local doctor probe configured local model endpoints (chat and embed)
108
146
  (reads local in ${MANIFEST_FILE}; opens no socket when
109
147
  unset; refuses non-loopback URLs without --allow-remote)
@@ -192,6 +230,9 @@ const FLAGS = `--json structured output (compact; one document,
192
230
  --task "<text>" the task text used for capability resolution
193
231
  --paths <globs> comma-separated path globs used for path resolution
194
232
  --root <path> repository root (default: cwd)
233
+ --base <ref> pr-body: the ref the body diffs against
234
+ (default: origin/main, then main; falls back to the
235
+ worktree diff against HEAD when neither exists)
195
236
  --out <path> plugin: where the generated package lands (required)
196
237
  --surfaces <ids> comma-separated editors to write rule files for;
197
238
  "" means AGENTS.md only (default: every surface,
@@ -202,6 +243,13 @@ const FLAGS = `--json structured output (compact; one document,
202
243
  --dry-run report what would change, change nothing
203
244
  --yes assume yes; required in automation, never prompts
204
245
  --check report drift and write nothing (sync, ci)
246
+ --diff print the plan as unified diffs (every writer;
247
+ changes nothing about what the run writes)
248
+ --line status: print the one-line gate summary from the last
249
+ check (the only mode status has)
250
+ --fix doctor: apply the remedies flagged safe (local,
251
+ idempotent, journaled); list the rest (dry-run
252
+ unless --yes)
205
253
  --frozen check: the lock must match this projection; an output
206
254
  change no input change explains fails the gate
207
255
  --explain sync: name each line dropped as already inferable, with
@@ -238,8 +286,10 @@ const FLAGS = `--json structured output (compact; one document,
238
286
  --reps <n> bench run: repetitions per task and arm (default: 3)
239
287
  --regrade <run> bench run: re-grade a stored run from its archived agent patch
240
288
  --compare <a> <b> bench report: compare two arms with deltas and p-values
241
- --test <cmd> plan done: the test command that closes the row
289
+ --test <cmd> plan done / slice finish: the test command that closes the row
242
290
  --state <name> plan done: target state (default: Complete)
291
+ --type <name> slice finish: the Conventional Commits type (default: feat)
292
+ --ttl <seconds> lease: how long a claim lasts (default: 3600)
243
293
  --level <name> map: detail level (outline, names, signatures)
244
294
  --supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
245
295
  --source <spec> pack new: where the pack's bytes come from
@@ -268,9 +318,12 @@ const PRINCIPLES = `design rules:
268
318
  bounded output one fact per line, fixed field order, explicit TRUNCATED lines`;
269
319
 
270
320
  export async function help(argv) {
271
- const { ok, value } = parse(argv);
272
- if (!ok) return value.code;
273
- const { flags } = value;
321
+ const parsed = parse(argv);
322
+ if (!parsed.ok) {
323
+ jsonFailMode(rawJsonFlag(argv));
324
+ return fail(parsed.error.message, parsed.error.code);
325
+ }
326
+ const { flags } = parsed.value;
274
327
  if (flags.version) {
275
328
  emit((await version()).trim());
276
329
  return 0;
@@ -300,10 +353,6 @@ export async function help(argv) {
300
353
  }
301
354
 
302
355
  async function version() {
303
- const { readFile } = await import('node:fs/promises');
304
- const { fileURLToPath } = await import('node:url');
305
- const { dirname, join } = await import('node:path');
306
- const root = join(dirname(fileURLToPath(import.meta.url)), '..');
307
- const pkg = JSON.parse(await readFile(join(root, '..', 'package.json'), 'utf8'));
308
- return `simt-dev ${pkg.version}\n`;
356
+ const { describeVersion } = await import('../package-version.mjs');
357
+ return `${describeVersion()}\n`;
309
358
  }