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
@@ -16,18 +16,21 @@
16
16
  // three `.mdc` rules would let a reader believe a repo is fully current when
17
17
  // only the file they happened to open is.
18
18
 
19
- import { mkdir, readFile, stat, symlink, unlink, writeFile } from 'node:fs/promises';
19
+ import { mkdir, readFile, stat, symlink, unlink } from 'node:fs/promises';
20
20
  import { dirname, join, resolve, sep } from 'node:path';
21
21
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
22
22
  import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
23
23
  import { fingerprint } from '../resolve/fingerprint.mjs';
24
- import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText } from '../projection/manifest.mjs';
24
+ import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText, manifestPath } from '../projection/manifest.mjs';
25
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
25
26
  import { readCapability } from '../hooks/capability.mjs';
26
27
  import { surfacesLine } from '../projection/surfaces.mjs';
27
28
  import { CLI } from '../name.mjs';
28
29
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
30
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
31
  import { resolvePacks } from '../packs.mjs';
32
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
33
+ import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
31
34
  import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
32
35
  import { runDeclaredChecks } from '../kernel/checks.mjs';
33
36
  import { DECISIONS_DIR } from '../decisions/index.mjs';
@@ -249,6 +252,29 @@ export async function syncCommand(positional, flags) {
249
252
  }
250
253
  const manifestChanged = manifestPlan !== null;
251
254
 
255
+ // The `--diff` pairs, read before anything is written so the before side is
256
+ // true in preview and in apply alike (R50). Pointers carry no bytes, so the
257
+ // plan line naming the link target is already the whole story for them.
258
+ const diffs =
259
+ flags.diff === true
260
+ ? await Promise.all([
261
+ ...changed.map(async (t) => ({
262
+ path: t.file,
263
+ before: await readBefore(t.path),
264
+ after: t.text,
265
+ })),
266
+ ...(manifestPlan === null
267
+ ? []
268
+ : [
269
+ {
270
+ path: manifestPlan.file,
271
+ before: await readBefore(await manifestPath(root)),
272
+ after: manifestPlan.text,
273
+ },
274
+ ]),
275
+ ])
276
+ : [];
277
+
252
278
  // Writes happen BEFORE output in every mode. An earlier build emitted `--json`
253
279
  // first and returned, so `upgrade --yes --json` reported a plan and changed
254
280
  // nothing — the exact combination automation reaches for.
@@ -265,11 +291,14 @@ export async function syncCommand(positional, flags) {
265
291
  // A comment that enumerates which commands follow a rule is a claim about code
266
292
  // this file does not own, and it was one commit out of date before anyone ran it.
267
293
  if (mode.apply) {
294
+ // Every repo-file write below is journaled, so `undo` can restore this run
295
+ // (R49). Pointer symlinks carry no bytes and are not journaled.
296
+ const journal = [];
268
297
  // The manifest first, with the rest: a gate that refuses on a stale stamp should
269
298
  // not be able to read a half-finished write, and the manifest is the file every
270
299
  // other write in this run is described by.
271
300
  if (manifestPlan) {
272
- await writeManifest(root, manifestPlan.manifest);
301
+ await writeManifest(root, manifestPlan.manifest, journal);
273
302
  note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
274
303
  }
275
304
  for (const t of changed) {
@@ -280,7 +309,7 @@ export async function syncCommand(positional, flags) {
280
309
  // derived from the target, never from the surface table, so a future surface with a
281
310
  // deeper path needs no change here.
282
311
  await mkdir(dirname(t.path), { recursive: true });
283
- await writeFile(t.path, t.text);
312
+ await journaledWriteFile(root, journal, t.path, t.text);
284
313
  note(`wrote ${t.file}`);
285
314
  }
286
315
  const lock = await computeLock(root, flags);
@@ -292,9 +321,16 @@ export async function syncCommand(positional, flags) {
292
321
  if (error.code !== 'ENOENT') throw error;
293
322
  }
294
323
  if (previousLock !== lockText) {
295
- await writeFile(join(root, LOCK_FILE), lockText);
324
+ await journaledWriteFile(root, journal, join(root, LOCK_FILE), lockText);
296
325
  note(`wrote ${LOCK_FILE}`);
297
326
  }
327
+ await commitJournal(root, 'sync', journal);
328
+ // The regions moved, so the last `check` no longer describes this tree.
329
+ // Invalidated rather than refreshed: `sync` does not run gates, checkers
330
+ // or decisions lint, so it cannot know what the gate would now say (R48).
331
+ // Gated on the journal: a no-op apply changed nothing, and the record is
332
+ // still true.
333
+ if (journal.length > 0) await invalidateCheckCache(root);
298
334
  for (const p of pointerPlan) {
299
335
  await mkdir(dirname(p.path), { recursive: true });
300
336
  // Only a symlink this tool wrote is replaced, and only because it has no content:
@@ -328,6 +364,7 @@ export async function syncCommand(positional, flags) {
328
364
  pointers: pointersAdded,
329
365
  written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
330
366
  dryRun: !mode.apply,
367
+ ...diffField(diffs, flags),
331
368
  },
332
369
  {},
333
370
  );
@@ -360,6 +397,8 @@ export async function syncCommand(positional, flags) {
360
397
  );
361
398
  }
362
399
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
400
+ // After the report: the report says what, the diff shows it (R50).
401
+ emitDiffs(diffs, flags);
363
402
  }
364
403
 
365
404
  const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
@@ -387,7 +426,15 @@ export async function syncCommand(positional, flags) {
387
426
  * @returns {Promise<{file: string, manifest: object, text: string,
388
427
  * stale: string[], previousBytes: number}|null>}
389
428
  */
390
- async function planManifestRepair(root, stack, sha) {
429
+ /**
430
+ * The manifest-only repair `sync` applies before rewriting regions (plan 0003 R47).
431
+ *
432
+ * Exported so `doctor --fix` applies exactly this repair instead of deriving its
433
+ * own: the generated fields have one author, and a fix that patched them any
434
+ * other way would be a second answer to what "current" means. Returns null when
435
+ * there is nothing to repair, under exactly the conditions `sync` writes under.
436
+ */
437
+ export async function planManifestRepair(root, stack, sha) {
391
438
  const drift = await manifestDrift(root, stack, sha);
392
439
  if (drift === null) return null;
393
440
  // A `reformatted` manifest is repaired only once the repository has declared that no
@@ -477,8 +524,14 @@ async function gateCwdFindings(root, manifest) {
477
524
  return findings;
478
525
  }
479
526
 
480
- export async function checkCommand(positional, flags) { const root = flags.root ?? positional[0] ?? process.cwd();
481
- const findings = await checkTargets(root, flags);
527
+ // --- core: the gate both the CLI and the MCP `check` tool answer from (Q15) ----
528
+ //
529
+ // The MCP tool rebuilt a smaller gate from `checkTargets` alone: no manifest,
530
+ // no gates, no pin, no lock, no declared checkers — 3 findings where the command
531
+ // reports 77. The command below renders these facts for terminals; the tool
532
+ // returns them as JSON. The CLI keeps its field names; the tool adopts them.
533
+ export async function checkRepo(root, opts = {}) {
534
+ const findings = await checkTargets(root, opts);
482
535
  const rules = await loadRules(root);
483
536
 
484
537
  // A rule file with no id would be projected without a traceable source, so it
@@ -494,10 +547,21 @@ export async function checkCommand(positional, flags) { const root = flags.root
494
547
  }
495
548
  }
496
549
 
550
+ // The rules slot of the `--line` cache, captured before anything else pushes:
551
+ // regions and rule sources are the projection, and a new blocking status
552
+ // that arrives here defaults to red rather than needing a second edit.
553
+ const rulesOk = !findings.some((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
554
+
497
555
  // A declared gate this repo cannot run fails closed on every commit once hooks are
498
556
  // installed. Reported here rather than left to `hooks status`, so the repo is red
499
557
  // before anyone installs anything rather than after.
500
- const { manifest } = await readManifest(root);
558
+ const { manifest, exists } = await readManifest(root);
559
+ // The gates slot, counted — not just reported — so the line can print run/total.
560
+ // A cwd finding names a declared gate that will not run, so it counts with the
561
+ // unrunnable; an unknown-gate-cwd names no declared gate, so it does not.
562
+ let gatesRun = 0;
563
+ let gatesTotal = 0;
564
+ let hooksOk = true;
501
565
  if (manifest) {
502
566
  // The gate runs from whatever binary resolved it. For a repository without npm that
503
567
  // is the release binary in `.simt/bin` (ADR-098), and the only thing that knows
@@ -507,8 +571,15 @@ export async function checkCommand(positional, flags) { const root = flags.root
507
571
  if (mismatch) {
508
572
  findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
509
573
  }
510
- findings.push(...unrunnableGates(manifest, await readCapability(root)));
511
- findings.push(...(await gateCwdFindings(root, manifest)));
574
+ const unrunnable = unrunnableGates(manifest, await readCapability(root));
575
+ findings.push(...unrunnable);
576
+ const cwdFindings = await gateCwdFindings(root, manifest);
577
+ findings.push(...cwdFindings);
578
+ gatesTotal = (manifest.gates?.preCommit ?? []).length + (manifest.gates?.prePush ?? []).length;
579
+ const notRun =
580
+ unrunnable.filter((f) => f.status === 'unrunnable').length + cwdFindings.length;
581
+ gatesRun = Math.max(0, gatesTotal - notRun);
582
+ hooksOk = (await hooksSummary(root, manifest, exists)).ok;
512
583
  // A pack the repository declares but cannot produce is reported here rather
513
584
  // than when a rule happens to be missing, because that is the failure mode
514
585
  // this guards: the region still renders, it renders SHORT, and `check` would
@@ -523,7 +594,7 @@ export async function checkCommand(positional, flags) { const root = flags.root
523
594
  const drift = await manifestDrift(root, stack, await provenanceDigest());
524
595
  if (drift) findings.push(drift);
525
596
  }
526
- if (flags.frozen) findings.push(...(await frozenFindings(root, flags)));
597
+ if (opts.frozen) findings.push(...(await frozenFindings(root, opts)));
527
598
  // The checkers a selected rule declared (ADR-088). They read the rule source,
528
599
  // not the folded region, so a line that left the prompt is still the line that runs.
529
600
  const fp = await fingerprint(root);
@@ -547,16 +618,40 @@ export async function checkCommand(positional, flags) { const root = flags.root
547
618
  // acts on, so a finding it reports and does not fail on is a decision recorded in a
548
619
  // set — not one inherited from the writer that shares this file (ADR-051).
549
620
  const bad = findings.filter((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
621
+ // The `--line` cache is local state, not a write: counts and booleans under
622
+ // `.simt/`, never repo content, the same standing as the ledger and the
623
+ // index. Written on every run — red state is state — so a prompt never
624
+ // reports the tree as it was two checks ago.
625
+ await writeCheckCache(
626
+ root,
627
+ await buildCacheRecord(root, {
628
+ rulesOk,
629
+ hooksOk,
630
+ gatesRun,
631
+ gatesTotal,
632
+ blocking: bad.length,
633
+ warnings: findings.filter((f) => CHECK_NON_BLOCKING.has(f.status)).length,
634
+ }),
635
+ );
636
+ return { ok: bad.length === 0, findings };
637
+ }
638
+
639
+ export async function checkCommand(positional, flags) {
640
+ const root = flags.root ?? positional[0] ?? process.cwd();
641
+ const { ok, findings } = await checkRepo(root, flags);
550
642
  const payload = {
551
- ok: bad.length === 0,
643
+ ok,
552
644
  command: 'check',
553
645
  root,
554
646
  findings,
647
+ // Uniform with every writer's `--json --diff`: the gate carries no file
648
+ // plan, so its diff is always empty — pinned, not excused (R50).
649
+ ...diffField([], flags),
555
650
  };
556
651
 
557
652
  if (isJson(flags)) {
558
653
  emitJson(payload, {});
559
- return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
654
+ return ok ? EXIT.OK : EXIT.FAIL;
560
655
  }
561
656
 
562
657
  // Every finding that carries a remedy prints it. A finding with a fix the reader
@@ -570,7 +665,7 @@ export async function checkCommand(positional, flags) { const root = flags.root
570
665
  .join('\n'),
571
666
  { maxBytes: flags.maxBytes ?? 0 },
572
667
  );
573
- return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
668
+ return ok ? EXIT.OK : EXIT.FAIL;
574
669
  }
575
670
 
576
671
  /**
@@ -0,0 +1,106 @@
1
+ // `simt undo [--yes] [--json]`
2
+ //
3
+ // Restore the last journaled set of writes (plan 0003 R49): every file the last
4
+ // writing run changed goes back to its pre-run bytes, and files the run created
5
+ // are deleted. A run that wrote nothing leaves no set, so there is nothing to
6
+ // restore and the command says so.
7
+ //
8
+ // The safety is the journal's, not the flag's: when any file in the set no
9
+ // longer matches the bytes the run left, the whole undo is refused — restoring
10
+ // the rest around a hand edit would silently merge two authors' intentions.
11
+ // Like every other writer this command previews without `--yes`.
12
+
13
+ import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
14
+ import { CLI } from '../name.mjs';
15
+ import { listJournalSets, readJournalSet, undoLastJournalSet } from '../journal.mjs';
16
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
17
+ import { join } from 'node:path';
18
+ import { invalidateCheckCache } from '../status.mjs';
19
+
20
+ export async function undoCommand(positional, flags) {
21
+ const root = flags.root ?? positional[0] ?? process.cwd();
22
+ const mode = writeMode(flags);
23
+
24
+ const [id] = await listJournalSets(root);
25
+ if (!id) {
26
+ if (isJson(flags)) {
27
+ emitJson({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply });
28
+ return EXIT.OK;
29
+ }
30
+ emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
31
+ return EXIT.OK;
32
+ }
33
+
34
+ const set = await readJournalSet(root, id);
35
+ const paths = set.entries.map((e) => e.path);
36
+ // The `--diff` pairs: what the tree holds now against what the restore
37
+ // would put back. A null beforeBytes is a file the run created, so the
38
+ // restore deletes it — the after side is empty (R50).
39
+ const diffs =
40
+ flags.diff === true
41
+ ? await Promise.all(
42
+ set.entries.map(async (e) => ({
43
+ path: e.path,
44
+ before: await readBefore(join(root, e.path)),
45
+ after: e.beforeBytes ?? null,
46
+ })),
47
+ )
48
+ : [];
49
+
50
+ if (!mode.apply) {
51
+ if (isJson(flags)) {
52
+ emitJson({ ok: true, command: 'undo', root, set: id, restores: paths, dryRun: true, ...diffField(diffs, flags) });
53
+ return EXIT.OK;
54
+ }
55
+ emit(
56
+ [`last write ${set.command} (${id})`, ...paths.map((p) => `restore ${p}`), '', `would restore ${paths.length} file(s); apply with: ${CLI} undo --yes`].join(
57
+ '\n',
58
+ ),
59
+ { maxBytes: flags.maxBytes ?? 0 },
60
+ );
61
+ // After the report: the report says what, the diff shows it (R50).
62
+ emitDiffs(diffs, flags);
63
+ return EXIT.OK;
64
+ }
65
+
66
+ const result = await undoLastJournalSet(root);
67
+ if (result.empty) {
68
+ if (isJson(flags)) {
69
+ emitJson({ ok: true, command: 'undo', root, set: null, restored: [] });
70
+ return EXIT.OK;
71
+ }
72
+ emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
73
+ return EXIT.OK;
74
+ }
75
+ if (result.refused) {
76
+ if (isJson(flags)) {
77
+ emitJson({ ok: false, command: 'undo', root, set: result.set, refused: result.refused, restored: [] });
78
+ return EXIT.FAIL;
79
+ }
80
+ emit(
81
+ [
82
+ `refused ${result.set}`,
83
+ `touched ${result.refused.path} ${result.refused.detail}`,
84
+ '',
85
+ 'nothing was restored; re-run the writing command or merge by hand, then undo again',
86
+ ].join('\n'),
87
+ { maxBytes: flags.maxBytes ?? 0 },
88
+ );
89
+ return EXIT.FAIL;
90
+ }
91
+ // The tree is back to before the run, so a `check` from after the run no
92
+ // longer describes it — the line goes back to "no cache" (R48). A restored
93
+ // set always restored something, so there is nothing to gate on.
94
+ await invalidateCheckCache(root);
95
+ if (isJson(flags)) {
96
+ emitJson({ ok: true, command: 'undo', root, set: result.set, restored: result.restored, ...diffField(diffs, flags) });
97
+ return EXIT.OK;
98
+ }
99
+ emit(
100
+ [`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
101
+ { maxBytes: flags.maxBytes ?? 0 },
102
+ );
103
+ // After the report: the report says what, the diff shows it (R50).
104
+ emitDiffs(diffs, flags);
105
+ return EXIT.OK;
106
+ }
@@ -22,13 +22,16 @@
22
22
  // silently behind is the failure `compare` exists to surface, and this command must
23
23
  // not be the thing that creates it.
24
24
 
25
- import { mkdir, writeFile } from 'node:fs/promises';
25
+ import { mkdir } from 'node:fs/promises';
26
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
27
+ import { invalidateCheckCache } from '../status.mjs';
26
28
 
27
29
  import { basename, dirname, resolve } from 'node:path';
28
30
 
29
31
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
30
32
  import { provenanceDigest, plan } from '../projection/project.mjs';
31
- import { MANIFEST, MANIFEST_COMMENT, readManifest, writeManifest } from '../projection/manifest.mjs';
33
+ import { MANIFEST, MANIFEST_COMMENT, manifestPath, manifestText, readManifest, writeManifest } from '../projection/manifest.mjs';
34
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
32
35
  import { FAULT_STATES, planUpgrade, summariseUpgrade } from '../upgrade/plan.mjs';
33
36
  import { discover } from '../compare/discover.mjs';
34
37
  import { resolveWorkspace, targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
@@ -69,13 +72,57 @@ export async function upgradeCommand(positional, flags) {
69
72
  const sha = await provenanceDigest();
70
73
  const mode = writeMode(flags);
71
74
  const plans = [];
75
+ // The plan inputs, kept because the diff is built from them: `planUpgrade`
76
+ // keeps the verdict, not the bytes, and the apply path below re-plans per
77
+ // repo rather than reusing this loop's targets.
78
+ const inputs = new Map();
72
79
  for (const repo of found.repos) {
73
80
  const { manifest, exists, error } = await readManifest(repo);
74
81
  const result = await plan(repo);
82
+ inputs.set(repo, { targets: result.targets, manifest });
75
83
  plans.push(planUpgrade({ name: basename(repo), path: repo, manifest, manifestExists: exists, manifestError: error, targets: result.targets, sha }));
76
84
  }
77
85
  const summary = summariseUpgrade(plans);
78
86
 
87
+ // The `--diff` pairs, read before anything is written so the before side is
88
+ // true in preview and in apply alike (R50). Labels carry the repo name: one
89
+ // run diffs N repositories, and a bare filename would not say which. The
90
+ // manifest after-bytes are built the same way the apply path builds them —
91
+ // chosen keys kept, `$comment` refreshed, pin moved — so the diff shows the
92
+ // write rather than a second opinion about it.
93
+ const diffs =
94
+ flags.diff === true
95
+ ? (
96
+ await Promise.all(
97
+ plans
98
+ .filter((p) => p.action === 'updated')
99
+ .map(async (p) => {
100
+ const input = inputs.get(p.path) ?? { targets: [], manifest: null };
101
+ const pairs = await Promise.all(
102
+ input.targets
103
+ .filter((t) => t.action !== 'unchanged' && t.action !== 'malformed')
104
+ .map(async (t) => ({
105
+ path: `${p.name}/${t.file}`,
106
+ before: await readBefore(t.path),
107
+ after: t.text,
108
+ })),
109
+ );
110
+ const { $comment, ...chosen } = input.manifest ?? {};
111
+ pairs.push({
112
+ path: `${p.name}/${MANIFEST_FILE}`,
113
+ before: await readBefore(await manifestPath(p.path)),
114
+ after: manifestText({
115
+ ...MANIFEST_COMMENT,
116
+ ...chosen,
117
+ projection: { ...(input.manifest?.projection ?? {}), simtDevSha: sha },
118
+ }),
119
+ });
120
+ return pairs;
121
+ }),
122
+ )
123
+ ).flat()
124
+ : [];
125
+
79
126
  // Writes happen BEFORE the report, in every output mode. An earlier version emitted
80
127
  // the JSON payload and returned, which meant `--yes --json` reported a plan and
81
128
  // changed nothing - the exact combination a script uses for automation.
@@ -96,11 +143,14 @@ export async function upgradeCommand(positional, flags) {
96
143
  // Every planned target, not just AGENTS.md: a repo can have a current AGENTS.md
97
144
  // and three stale `.mdc` rules, and restamping only the first leaves `check` red
98
145
  // straight after a successful upgrade.
146
+ // One journal set per repo: a set belongs to one repository, and an
147
+ // `undo` in one consumer must never reach into another (R49).
148
+ const journal = [];
99
149
  const { targets } = await plan(p.path);
100
150
  for (const t of targets) {
101
151
  if (t.action === 'unchanged' || t.action === 'malformed') continue;
102
152
  await mkdir(dirname(t.path), { recursive: true });
103
- await writeFile(t.path, t.text);
153
+ await journaledWriteFile(p.path, journal, t.path, t.text);
104
154
  writtenFiles.add(t.file);
105
155
  }
106
156
  const { manifest } = await readManifest(p.path);
@@ -110,11 +160,20 @@ export async function upgradeCommand(positional, flags) {
110
160
  // forever, because `sync` does not own the manifest and nothing else rewrites
111
161
  // it. An adopted repo would never have corrected it.
112
162
  const { $comment, ...chosen } = manifest ?? {};
113
- await writeManifest(p.path, {
114
- ...MANIFEST_COMMENT,
115
- ...chosen,
116
- projection: { ...(manifest?.projection ?? {}), simtDevSha: sha },
117
- });
163
+ await writeManifest(
164
+ p.path,
165
+ {
166
+ ...MANIFEST_COMMENT,
167
+ ...chosen,
168
+ projection: { ...(manifest?.projection ?? {}), simtDevSha: sha },
169
+ },
170
+ journal,
171
+ );
172
+ await commitJournal(p.path, 'upgrade', journal);
173
+ // The pin and regions moved: the last `check` of this repo no longer
174
+ // describes it (R48). Gated on the journal: an untouched repo keeps
175
+ // its record.
176
+ if (journal.length > 0) await invalidateCheckCache(p.path);
118
177
  applied.push(p.name);
119
178
  } catch (error) {
120
179
  // Reported per repo rather than thrown: one unwritable repo must not hide the
@@ -133,6 +192,7 @@ export async function upgradeCommand(positional, flags) {
133
192
  source: target.source,
134
193
  sha,
135
194
  dryRun: !mode.apply,
195
+ ...diffField(diffs, flags),
136
196
  written: applied.length > 0,
137
197
  summary: { ...summary, applied, failed },
138
198
  plans,
@@ -144,6 +204,8 @@ export async function upgradeCommand(positional, flags) {
144
204
  }
145
205
 
146
206
  emit(render(plans, summary, { root, shape: found.shape, source: target.source, sha, applied, failed }));
207
+ // After the report: the report says what, the diff shows it (R50).
208
+ emitDiffs(diffs, flags);
147
209
 
148
210
  for (const w of found.worktrees) {
149
211
  // The same reasoning as `compare`: a worktree is its parent's HEAD elsewhere, so
@@ -0,0 +1,191 @@
1
+ // Shell completions, generated from the command registry (plan 0003 R51).
2
+ //
3
+ // The inputs are the two tables that already exist and are already checked:
4
+ // `COMMANDS` — the same list the dispatcher routes and `--help` is asserted
5
+ // against (review 0002 S15) — and the flag tables in argv.mjs. A command added
6
+ // to `IMPLEMENTED` therefore completes in all three shells with no second
7
+ // edit, which is exactly the property S15 demanded of `--help`.
8
+ //
9
+ // Deterministic by construction: every list is sorted and deduplicated before
10
+ // it is rendered, no timestamp, no host name, no environment is read. The same
11
+ // registry produces byte-identical output on any machine, so the generated
12
+ // script can be committed, diffed and reviewed like any other file.
13
+ //
14
+ // Two boundaries, both deliberate:
15
+ //
16
+ // - Positional VALUES beyond subcommand words are file completion. The
17
+ // registry knows words, not the skill catalogue or a path's tree, and a
18
+ // completion that guesses is worse than one that falls back to files.
19
+ // - bash's script stays 3.2-compatible: macOS ships /bin/bash 3.2, and a
20
+ // completion script that fails to parse there is a silent no-op.
21
+
22
+ import { BOOL_FLAGS, VALUE_FLAGS } from './argv.mjs';
23
+
24
+ /** The shells this command can render, sorted. */
25
+ export const SHELLS = ['bash', 'fish', 'zsh'];
26
+
27
+ /** Every flag name, sorted: `--budget`, `--root`, ... Flags are global. */
28
+ const FLAGS = [...new Set([...BOOL_FLAGS, ...VALUE_FLAGS])].sort().map((f) => `--${f}`);
29
+
30
+ /** The value-flag case pattern (`--root|--task|...`), for the prev-word check. */
31
+ const VALUE_CASE = [...VALUE_FLAGS].sort().map((f) => `--${f}`).join('|');
32
+
33
+ /**
34
+ * The registry as completions need it: the first words, and for each group the
35
+ * subcommands under it. Derived from `commands` rather than hand-listed, so a
36
+ * new command or subcommand arrives here without an edit.
37
+ *
38
+ * @param {string[]} commands sorted dispatchable paths, e.g. `['brief', 'skills index']`
39
+ * @returns {{top: string[], groups: Array<[string, string[]]>}}
40
+ */
41
+ export function shape(commands) {
42
+ const top = new Set();
43
+ const subs = new Map();
44
+ for (const path of commands) {
45
+ const [first, second] = path.split(' ');
46
+ top.add(first);
47
+ if (second !== undefined) {
48
+ if (!subs.has(first)) subs.set(first, new Set());
49
+ subs.get(first).add(second);
50
+ }
51
+ }
52
+ return {
53
+ top: [...top].sort(),
54
+ groups: [...subs.entries()]
55
+ .map(([group, set]) => [group, [...set].sort()])
56
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1)),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The completion script for one shell.
62
+ *
63
+ * @param {'bash'|'fish'|'zsh'} shell
64
+ * @param {string[]} commands the dispatchable command paths (`COMMANDS`)
65
+ * @returns {string} the script, newline-terminated
66
+ */
67
+ export function renderCompletions(shell, commands) {
68
+ const s = shape(commands);
69
+ if (shell === 'bash') return renderBash(s);
70
+ if (shell === 'zsh') return renderZsh(s);
71
+ if (shell === 'fish') return renderFish(s);
72
+ throw new Error(`unknown shell \`${shell}\`; one of: ${SHELLS.join(', ')}`);
73
+ }
74
+
75
+ function renderBash({ top, groups }) {
76
+ const cases = groups
77
+ .map(([group, subs]) => ` ${group}) subs='${subs.join(' ')}' ;;`)
78
+ .join('\n');
79
+ return [
80
+ '# bash completion for simt. Generated by `simt completions bash`; do not edit.',
81
+ '# Install: source <(simt completions bash), or drop it in /etc/bash_completion.d/.',
82
+ '# bash 3.2 compatible: macOS ships /bin/bash 3.2.',
83
+ '_simt() {',
84
+ ' local cur prev subs flags top',
85
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
86
+ " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
87
+ " subs=''",
88
+ '',
89
+ ' # A flag that takes a value takes a word only the shell can name: paths.',
90
+ ' case "$prev" in',
91
+ ' --) return 0 ;;',
92
+ ` ${VALUE_CASE}) return 0 ;;`,
93
+ ' esac',
94
+ '',
95
+ ` flags='${FLAGS.join(' ')}'`,
96
+ ` top='${top.join(' ')}'`,
97
+ '',
98
+ ' case "$cur" in',
99
+ ' -*) COMPREPLY=( $(compgen -W "$flags" -- "$cur") ); return 0 ;;',
100
+ ' esac',
101
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
102
+ ' COMPREPLY=( $(compgen -W "$top" -- "$cur") )',
103
+ ' elif [ "$COMP_CWORD" -eq 2 ]; then',
104
+ ' case "${COMP_WORDS[1]}" in',
105
+ cases,
106
+ ' esac',
107
+ ' if [ -n "$subs" ]; then',
108
+ ' COMPREPLY=( $(compgen -W "$subs" -- "$cur") )',
109
+ ' fi',
110
+ ' fi',
111
+ '}',
112
+ 'complete -o bashdefault -o default -F _simt simt',
113
+ '',
114
+ ].join('\n');
115
+ }
116
+
117
+ function renderZsh({ top, groups }) {
118
+ const cases = groups
119
+ .map(([group, subs]) => ` ${group}) subs=( ${subs.join(' ')} ) ;;`)
120
+ .join('\n');
121
+ return [
122
+ '#compdef simt',
123
+ '# zsh completion for simt. Generated by `simt completions zsh`; do not edit.',
124
+ '# Install: save as `_simt` in a directory on $fpath and run `compinit`, or',
125
+ '# source this after compinit: `source <(simt completions zsh)`.',
126
+ '_simt() {',
127
+ ' local cur prev',
128
+ ' local -a top flags subs',
129
+ ` top=( ${top.join(' ')} )`,
130
+ ` flags=( ${FLAGS.join(' ')} )`,
131
+ ' subs=()',
132
+ '',
133
+ ' # A flag that takes a value takes a word only the shell can name: paths.',
134
+ ' case "$prev" in',
135
+ ` --|${VALUE_CASE})`,
136
+ ' _files',
137
+ ' return',
138
+ ' ;;',
139
+ ' esac',
140
+ '',
141
+ ' if [[ "$cur" == -* ]]; then',
142
+ ' compadd -- ${flags}',
143
+ ' return',
144
+ ' fi',
145
+ '',
146
+ ' case "${words[2]}" in',
147
+ cases,
148
+ ' esac',
149
+ '',
150
+ ' if (( CURRENT == 2 )); then',
151
+ ' compadd -- ${top}',
152
+ ' elif (( CURRENT == 3 )) && (( ${#subs} )); then',
153
+ ' compadd -- ${subs}',
154
+ ' else',
155
+ ' _files',
156
+ ' fi',
157
+ '}',
158
+ '',
159
+ 'if (( $+functions[compdef] )); then',
160
+ ' compdef _simt simt',
161
+ 'fi',
162
+ '',
163
+ ].join('\n');
164
+ }
165
+
166
+ function renderFish({ top, groups }) {
167
+ const level1 = top.map(
168
+ (word) => `complete -c simt -n '__fish_use_subcommand' -a '${word}'`,
169
+ );
170
+ const level2 = groups.flatMap(([group, subs]) =>
171
+ subs.map(
172
+ (word) => `complete -c simt -n '__fish_seen_subcommand_from ${group}' -a '${word}'`,
173
+ ),
174
+ );
175
+ const flagLines = FLAGS.map((flag) => {
176
+ const name = flag.slice(2);
177
+ return VALUE_FLAGS.has(name)
178
+ ? `complete -c simt -l ${name} -r`
179
+ : `complete -c simt -l ${name}`;
180
+ });
181
+ return [
182
+ '# fish completion for simt. Generated by `simt completions fish`; do not edit.',
183
+ '# Install: simt completions fish > ~/.config/fish/completions/simt.fish',
184
+ ...level1,
185
+ '',
186
+ ...level2,
187
+ '',
188
+ ...flagLines,
189
+ '',
190
+ ].join('\n');
191
+ }