simt-dev 0.6.2 → 0.7.0

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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -0,0 +1,62 @@
1
+ // `simt status --line`
2
+ //
3
+ // One line from the last `check`, for shell prompts, tmux and agent status
4
+ // lines: `simt PASS ✓rules ✓hooks 3/3gates`. The line is the gate state *as
5
+ // of the last check* — it reads only `.simt/check.json` plus one
6
+ // `git rev-parse HEAD`, so it answers in milliseconds while `check` itself
7
+ // takes the time projection takes.
8
+ //
9
+ // Bare `status` is a usage error, the way `plan` without a subcommand is: the
10
+ // only mode this command has is `--line`, and running something else by
11
+ // default would invent a second answer to what the status is.
12
+
13
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { CLI } from '../name.mjs';
15
+ import { currentHead, readCheckCache, renderStatusLine } from '../status.mjs';
16
+
17
+ export async function statusCommand(args, flags) {
18
+ const root = flags.root ?? args[0] ?? process.cwd();
19
+ if (flags.line !== true) {
20
+ return fail('status needs --line', EXIT.USAGE, `run: ${CLI} status --line --root ${root}`);
21
+ }
22
+
23
+ const record = await readCheckCache(root);
24
+ if (record === null) {
25
+ // Informational, not a failure: a repository that never ran `check` — or
26
+ // one a writer just invalidated — has no state to report, and a prompt
27
+ // that errored until the first check would train its reader to ignore it.
28
+ if (isJson(flags)) {
29
+ emitJson({ ok: true, command: 'status', root, line: null, cached: false, stale: false });
30
+ return EXIT.OK;
31
+ }
32
+ emit('simt no cache - run check');
33
+ return EXIT.OK;
34
+ }
35
+
36
+ const head = await currentHead(root);
37
+ // Stale when HEAD moved since the check ran. When either side is unknown
38
+ // there is nothing to compare, so the record stands as written.
39
+ const stale = head !== null && record.head !== null && head !== record.head;
40
+ const line = renderStatusLine(record, stale);
41
+ const code = record.blocking === 0 ? EXIT.OK : EXIT.FAIL;
42
+
43
+ if (isJson(flags)) {
44
+ emitJson({
45
+ ok: code === EXIT.OK,
46
+ command: 'status',
47
+ root,
48
+ line,
49
+ cached: true,
50
+ stale,
51
+ rulesOk: record.rulesOk,
52
+ hooksOk: record.hooksOk,
53
+ gatesRun: record.gatesRun,
54
+ gatesTotal: record.gatesTotal,
55
+ blocking: record.blocking,
56
+ warnings: record.warnings,
57
+ });
58
+ return code;
59
+ }
60
+ emit(line);
61
+ return code;
62
+ }
@@ -16,21 +16,23 @@
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
- import { CLI } from '../name.mjs';
28
- import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
28
+ import { CLI, LOCK_FILE, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
29
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
30
  import { resolvePacks } from '../packs.mjs';
31
- import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
31
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
32
+ import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
33
+ import { computeLock, readLock } from '../projection/lock.mjs';
32
34
  import { runDeclaredChecks } from '../kernel/checks.mjs';
33
- import { DECISIONS_DIR } from '../decisions/index.mjs';
35
+ import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
34
36
  import { lintDecisions } from '../memory/decide.mjs';
35
37
 
36
38
  /** Findings `sync` reports and does not fail on.
@@ -61,6 +63,12 @@ export const SYNC_NON_BLOCKING = new Set([
61
63
  'budget-cut',
62
64
  'file-lines-warn',
63
65
  'check-unbuilt',
66
+ // Review 0003 Q28: the tsconfig strictness findings. The base file is repo-owned —
67
+ // init no longer writes it, so neither can `sync` — and both findings' remedies are
68
+ // a decision about the repo's own compiler policy, not a missing file. Blocking on
69
+ // them would fail every fresh TypeScript adoption over a file the tool does not write.
70
+ 'inert',
71
+ 'inconsistent',
64
72
  ]);
65
73
 
66
74
  /**
@@ -98,7 +106,21 @@ export const SYNC_NON_BLOCKING = new Set([
98
106
  // (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
99
107
  // remedy is a decision about the number — and a cut the repository asked for by declaring
100
108
  // a budget is the tool working, not a repository in need of a file.
101
- export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget', 'budget-cut', 'file-lines-warn', 'check-unbuilt']);
109
+ // `inert` and `inconsistent` are here for review 0003 Q28: the base file is repo-owned,
110
+ // so the gate reports the state of the repo's own compiler policy without failing it.
111
+ // A fresh TypeScript adoption has neither file in a state the tool can repair, and a
112
+ // gate that reddens on a policy decision is one the repo satisfies by choosing, not by
113
+ // running a command.
114
+ export const CHECK_NON_BLOCKING = new Set([
115
+ 'orphaned',
116
+ 'pack-unpinned',
117
+ 'budget',
118
+ 'budget-cut',
119
+ 'file-lines-warn',
120
+ 'check-unbuilt',
121
+ 'inert',
122
+ 'inconsistent',
123
+ ]);
102
124
 
103
125
  export async function syncCommand(positional, flags) {
104
126
  const root = flags.root ?? positional[0] ?? process.cwd();
@@ -150,6 +172,10 @@ export async function syncCommand(positional, flags) {
150
172
  );
151
173
  }
152
174
 
175
+ // A dangling cross-reference does not stop the projection: the region renders, and one
176
+ // unreachable reference is a defect to report, not a reason to withhold the rest.
177
+ for (const p of result.crossRefs ?? []) note(`PROBLEM ${p}`);
178
+
153
179
  const broken = result.targets.filter((t) => t.action === 'malformed');
154
180
  if (broken.length > 0) {
155
181
  const first = broken[0];
@@ -249,6 +275,29 @@ export async function syncCommand(positional, flags) {
249
275
  }
250
276
  const manifestChanged = manifestPlan !== null;
251
277
 
278
+ // The `--diff` pairs, read before anything is written so the before side is
279
+ // true in preview and in apply alike (R50). Pointers carry no bytes, so the
280
+ // plan line naming the link target is already the whole story for them.
281
+ const diffs =
282
+ flags.diff === true
283
+ ? await Promise.all([
284
+ ...changed.map(async (t) => ({
285
+ path: t.file,
286
+ before: await readBefore(t.path),
287
+ after: t.text,
288
+ })),
289
+ ...(manifestPlan === null
290
+ ? []
291
+ : [
292
+ {
293
+ path: manifestPlan.file,
294
+ before: await readBefore(await manifestPath(root)),
295
+ after: manifestPlan.text,
296
+ },
297
+ ]),
298
+ ])
299
+ : [];
300
+
252
301
  // Writes happen BEFORE output in every mode. An earlier build emitted `--json`
253
302
  // first and returned, so `upgrade --yes --json` reported a plan and changed
254
303
  // nothing — the exact combination automation reaches for.
@@ -265,11 +314,14 @@ export async function syncCommand(positional, flags) {
265
314
  // A comment that enumerates which commands follow a rule is a claim about code
266
315
  // this file does not own, and it was one commit out of date before anyone ran it.
267
316
  if (mode.apply) {
317
+ // Every repo-file write below is journaled, so `undo` can restore this run
318
+ // (R49). Pointer symlinks carry no bytes and are not journaled.
319
+ const journal = [];
268
320
  // The manifest first, with the rest: a gate that refuses on a stale stamp should
269
321
  // not be able to read a half-finished write, and the manifest is the file every
270
322
  // other write in this run is described by.
271
323
  if (manifestPlan) {
272
- await writeManifest(root, manifestPlan.manifest);
324
+ await writeManifest(root, manifestPlan.manifest, journal);
273
325
  note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
274
326
  }
275
327
  for (const t of changed) {
@@ -278,23 +330,20 @@ export async function syncCommand(positional, flags) {
278
330
  // mkdir; `sync` did not, so the first `sync` after a surface was added failed with
279
331
  // ENOENT on the second target and had already written the first. The parent is
280
332
  // derived from the target, never from the surface table, so a future surface with a
281
- // deeper path needs no change here.
333
+ // deeper path needs no change here. The lock is one of these targets (Q29): it
334
+ // reaches this loop like every other file, so a deleted lock is a write the plan
335
+ // shows and the journal records.
282
336
  await mkdir(dirname(t.path), { recursive: true });
283
- await writeFile(t.path, t.text);
337
+ await journaledWriteFile(root, journal, t.path, t.text);
284
338
  note(`wrote ${t.file}`);
285
339
  }
286
- const lock = await computeLock(root, flags);
287
- const lockText = `${JSON.stringify(lock, null, 2)}\n`;
288
- let previousLock = null;
289
- try {
290
- previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
291
- } catch (error) {
292
- if (error.code !== 'ENOENT') throw error;
293
- }
294
- if (previousLock !== lockText) {
295
- await writeFile(join(root, LOCK_FILE), lockText);
296
- note(`wrote ${LOCK_FILE}`);
297
- }
340
+ await commitJournal(root, 'sync', journal);
341
+ // The regions moved, so the last `check` no longer describes this tree.
342
+ // Invalidated rather than refreshed: `sync` does not run gates, checkers
343
+ // or decisions lint, so it cannot know what the gate would now say (R48).
344
+ // Gated on the journal: a no-op apply changed nothing, and the record is
345
+ // still true.
346
+ if (journal.length > 0) await invalidateCheckCache(root);
298
347
  for (const p of pointerPlan) {
299
348
  await mkdir(dirname(p.path), { recursive: true });
300
349
  // Only a symlink this tool wrote is replaced, and only because it has no content:
@@ -328,6 +377,7 @@ export async function syncCommand(positional, flags) {
328
377
  pointers: pointersAdded,
329
378
  written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
330
379
  dryRun: !mode.apply,
380
+ ...diffField(diffs, flags),
331
381
  },
332
382
  {},
333
383
  );
@@ -360,6 +410,8 @@ export async function syncCommand(positional, flags) {
360
410
  );
361
411
  }
362
412
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
413
+ // After the report: the report says what, the diff shows it (R50).
414
+ emitDiffs(diffs, flags);
363
415
  }
364
416
 
365
417
  const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
@@ -387,7 +439,15 @@ export async function syncCommand(positional, flags) {
387
439
  * @returns {Promise<{file: string, manifest: object, text: string,
388
440
  * stale: string[], previousBytes: number}|null>}
389
441
  */
390
- async function planManifestRepair(root, stack, sha) {
442
+ /**
443
+ * The manifest-only repair `sync` applies before rewriting regions (plan 0003 R47).
444
+ *
445
+ * Exported so `doctor --fix` applies exactly this repair instead of deriving its
446
+ * own: the generated fields have one author, and a fix that patched them any
447
+ * other way would be a second answer to what "current" means. Returns null when
448
+ * there is nothing to repair, under exactly the conditions `sync` writes under.
449
+ */
450
+ export async function planManifestRepair(root, stack, sha) {
391
451
  const drift = await manifestDrift(root, stack, sha);
392
452
  if (drift === null) return null;
393
453
  // A `reformatted` manifest is repaired only once the repository has declared that no
@@ -477,8 +537,14 @@ async function gateCwdFindings(root, manifest) {
477
537
  return findings;
478
538
  }
479
539
 
480
- export async function checkCommand(positional, flags) { const root = flags.root ?? positional[0] ?? process.cwd();
481
- const findings = await checkTargets(root, flags);
540
+ // --- core: the gate both the CLI and the MCP `check` tool answer from (Q15) ----
541
+ //
542
+ // The MCP tool rebuilt a smaller gate from `checkTargets` alone: no manifest,
543
+ // no gates, no pin, no lock, no declared checkers — 3 findings where the command
544
+ // reports 77. The command below renders these facts for terminals; the tool
545
+ // returns them as JSON. The CLI keeps its field names; the tool adopts them.
546
+ export async function checkRepo(root, opts = {}) {
547
+ const findings = await checkTargets(root, opts);
482
548
  const rules = await loadRules(root);
483
549
 
484
550
  // A rule file with no id would be projected without a traceable source, so it
@@ -494,10 +560,21 @@ export async function checkCommand(positional, flags) { const root = flags.root
494
560
  }
495
561
  }
496
562
 
563
+ // The rules slot of the `--line` cache, captured before anything else pushes:
564
+ // regions and rule sources are the projection, and a new blocking status
565
+ // that arrives here defaults to red rather than needing a second edit.
566
+ const rulesOk = !findings.some((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
567
+
497
568
  // A declared gate this repo cannot run fails closed on every commit once hooks are
498
569
  // installed. Reported here rather than left to `hooks status`, so the repo is red
499
570
  // before anyone installs anything rather than after.
500
- const { manifest } = await readManifest(root);
571
+ const { manifest, exists } = await readManifest(root);
572
+ // The gates slot, counted — not just reported — so the line can print run/total.
573
+ // A cwd finding names a declared gate that will not run, so it counts with the
574
+ // unrunnable; an unknown-gate-cwd names no declared gate, so it does not.
575
+ let gatesRun = 0;
576
+ let gatesTotal = 0;
577
+ let hooksOk = true;
501
578
  if (manifest) {
502
579
  // The gate runs from whatever binary resolved it. For a repository without npm that
503
580
  // is the release binary in `.simt/bin` (ADR-098), and the only thing that knows
@@ -507,8 +584,15 @@ export async function checkCommand(positional, flags) { const root = flags.root
507
584
  if (mismatch) {
508
585
  findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
509
586
  }
510
- findings.push(...unrunnableGates(manifest, await readCapability(root)));
511
- findings.push(...(await gateCwdFindings(root, manifest)));
587
+ const unrunnable = unrunnableGates(manifest, await readCapability(root));
588
+ findings.push(...unrunnable);
589
+ const cwdFindings = await gateCwdFindings(root, manifest);
590
+ findings.push(...cwdFindings);
591
+ gatesTotal = (manifest.gates?.preCommit ?? []).length + (manifest.gates?.prePush ?? []).length;
592
+ const notRun =
593
+ unrunnable.filter((f) => f.status === 'unrunnable').length + cwdFindings.length;
594
+ gatesRun = Math.max(0, gatesTotal - notRun);
595
+ hooksOk = (await hooksSummary(root, manifest, exists)).ok;
512
596
  // A pack the repository declares but cannot produce is reported here rather
513
597
  // than when a rule happens to be missing, because that is the failure mode
514
598
  // this guards: the region still renders, it renders SHORT, and `check` would
@@ -523,20 +607,22 @@ export async function checkCommand(positional, flags) { const root = flags.root
523
607
  const drift = await manifestDrift(root, stack, await provenanceDigest());
524
608
  if (drift) findings.push(drift);
525
609
  }
526
- if (flags.frozen) findings.push(...(await frozenFindings(root, flags)));
610
+ if (opts.frozen) findings.push(...(await frozenFindings(root, opts)));
527
611
  // The checkers a selected rule declared (ADR-088). They read the rule source,
528
612
  // not the folded region, so a line that left the prompt is still the line that runs.
529
613
  const fp = await fingerprint(root);
530
614
  const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
531
615
  findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
532
616
 
533
- const decisionsDir = join(root, 'docs', 'decisions');
534
- const decisionLint = await lintDecisions(decisionsDir, {
535
- allowHistorical: resolve(decisionsDir) === DECISIONS_DIR,
617
+ // Q55: one resolution shared with `decide --check`, so the two commands
618
+ // cannot disagree about whose records these are or which are historical.
619
+ const relDecisionsDir = await decisionsDir(root);
620
+ const decisionLint = await lintDecisions(join(root, relDecisionsDir), {
621
+ historicalCutoff: await decisionsCutoff(root),
536
622
  });
537
623
  findings.push(
538
624
  ...decisionLint.findings.map((f) => ({
539
- file: ['docs', 'decisions', f.file].join('/'),
625
+ file: [relDecisionsDir, f.file].join('/'),
540
626
  status: f.status,
541
627
  detail: f.detail,
542
628
  remedy: f.remedy,
@@ -547,16 +633,40 @@ export async function checkCommand(positional, flags) { const root = flags.root
547
633
  // acts on, so a finding it reports and does not fail on is a decision recorded in a
548
634
  // set — not one inherited from the writer that shares this file (ADR-051).
549
635
  const bad = findings.filter((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
636
+ // The `--line` cache is local state, not a write: counts and booleans under
637
+ // `.simt/`, never repo content, the same standing as the ledger and the
638
+ // index. Written on every run — red state is state — so a prompt never
639
+ // reports the tree as it was two checks ago.
640
+ await writeCheckCache(
641
+ root,
642
+ await buildCacheRecord(root, {
643
+ rulesOk,
644
+ hooksOk,
645
+ gatesRun,
646
+ gatesTotal,
647
+ blocking: bad.length,
648
+ warnings: findings.filter((f) => CHECK_NON_BLOCKING.has(f.status)).length,
649
+ }),
650
+ );
651
+ return { ok: bad.length === 0, findings };
652
+ }
653
+
654
+ export async function checkCommand(positional, flags) {
655
+ const root = flags.root ?? positional[0] ?? process.cwd();
656
+ const { ok, findings } = await checkRepo(root, flags);
550
657
  const payload = {
551
- ok: bad.length === 0,
658
+ ok,
552
659
  command: 'check',
553
660
  root,
554
661
  findings,
662
+ // Uniform with every writer's `--json --diff`: the gate carries no file
663
+ // plan, so its diff is always empty — pinned, not excused (R50).
664
+ ...diffField([], flags),
555
665
  };
556
666
 
557
667
  if (isJson(flags)) {
558
668
  emitJson(payload, {});
559
- return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
669
+ return ok ? EXIT.OK : EXIT.FAIL;
560
670
  }
561
671
 
562
672
  // Every finding that carries a remedy prints it. A finding with a fix the reader
@@ -570,7 +680,7 @@ export async function checkCommand(positional, flags) { const root = flags.root
570
680
  .join('\n'),
571
681
  { maxBytes: flags.maxBytes ?? 0 },
572
682
  );
573
- return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
683
+ return ok ? EXIT.OK : EXIT.FAIL;
574
684
  }
575
685
 
576
686
  /**
@@ -13,7 +13,7 @@ import { promisify } from 'node:util';
13
13
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
14
  import { CLI } from '../name.mjs';
15
15
  import { isTestPath } from '../bench/mine.mjs';
16
- import { blobTexts, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
16
+ import { blobTexts, isIndexStale, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
17
17
  import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
18
18
 
19
19
  const exec = promisify(execFile);
@@ -84,7 +84,10 @@ function summarise(root, flags, edges) {
84
84
 
85
85
  async function loadFiles(root) {
86
86
  let files = await readFiles(root);
87
- if (files.length === 0) {
87
+ // An empty index has nothing to answer from; a stale one has yesterday's
88
+ // tree (review 0003 Q42). Either way the refresh is incremental, so a
89
+ // current repository pays for one `git ls-files -s`, not a rebuild.
90
+ if (files.length === 0 || (await isIndexStale(root))) {
88
91
  try {
89
92
  await reindex(root);
90
93
  } catch (error) {
@@ -0,0 +1,114 @@
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, files the run created
5
+ // are deleted, and git config keys the run set go back to their pre-run
6
+ // values (review 0003 Q22). A run that wrote nothing leaves no set, so there
7
+ // is nothing to restore and the command says so.
8
+ //
9
+ // The safety is the journal's, not the flag's: when any file in the set no
10
+ // longer matches the bytes the run left, the whole undo is refused — restoring
11
+ // the rest around a hand edit would silently merge two authors' intentions.
12
+ // Like every other writer this command previews without `--yes`.
13
+
14
+ import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
15
+ import { CLI } from '../name.mjs';
16
+ import { beforeText, entryLabel, listJournalSets, readGitValue, readJournalSet, undoLastJournalSet } from '../journal.mjs';
17
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
18
+ import { join } from 'node:path';
19
+ import { invalidateCheckCache } from '../status.mjs';
20
+
21
+ export async function undoCommand(positional, flags) {
22
+ const root = flags.root ?? positional[0] ?? process.cwd();
23
+ const mode = writeMode(flags);
24
+
25
+ const [id] = await listJournalSets(root);
26
+ if (!id) {
27
+ if (isJson(flags)) {
28
+ emitJson({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply });
29
+ return EXIT.OK;
30
+ }
31
+ emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
32
+ return EXIT.OK;
33
+ }
34
+
35
+ const set = await readJournalSet(root, id);
36
+ const labels = set.entries.map(entryLabel);
37
+ // The `--diff` pairs: what the tree holds now against what the restore
38
+ // would put back. A null before is a file the run created (or a key it
39
+ // set from unset), so the restore deletes it — the after side is empty
40
+ // (R50).
41
+ const diffs =
42
+ flags.diff === true
43
+ ? await Promise.all(
44
+ set.entries.map(async (e) => {
45
+ if (e.kind === 'git-config') {
46
+ return { path: entryLabel(e), before: await readGitValue(root, e.key), after: e.before ?? null };
47
+ }
48
+ return { path: e.path, before: await readBefore(join(root, e.path)), after: beforeText(e) };
49
+ }),
50
+ )
51
+ : [];
52
+
53
+ if (!mode.apply) {
54
+ if (isJson(flags)) {
55
+ emitJson({ ok: true, command: 'undo', root, set: id, restores: labels, dryRun: true, ...diffField(diffs, flags) });
56
+ return EXIT.OK;
57
+ }
58
+ const files = set.entries.filter((e) => e.kind !== 'git-config').length;
59
+ const keys = set.entries.length - files;
60
+ emit(
61
+ [
62
+ `last write ${set.command} (${id})`,
63
+ ...labels.map((p) => `restore ${p}`),
64
+ '',
65
+ `would restore ${files} file(s)${keys > 0 ? ` and ${keys} git config key(s)` : ''}; apply with: ${CLI} undo --yes`,
66
+ ].join('\n'),
67
+ { maxBytes: flags.maxBytes ?? 0 },
68
+ );
69
+ // After the report: the report says what, the diff shows it (R50).
70
+ emitDiffs(diffs, flags);
71
+ return EXIT.OK;
72
+ }
73
+
74
+ const result = await undoLastJournalSet(root);
75
+ if (result.empty) {
76
+ if (isJson(flags)) {
77
+ emitJson({ ok: true, command: 'undo', root, set: null, restored: [] });
78
+ return EXIT.OK;
79
+ }
80
+ emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
81
+ return EXIT.OK;
82
+ }
83
+ if (result.refused) {
84
+ if (isJson(flags)) {
85
+ emitJson({ ok: false, command: 'undo', root, set: result.set, refused: result.refused, restored: [] });
86
+ return EXIT.FAIL;
87
+ }
88
+ emit(
89
+ [
90
+ `refused ${result.set}`,
91
+ `touched ${result.refused.path} ${result.refused.detail}`,
92
+ '',
93
+ 'nothing was restored; re-run the writing command or merge by hand, then undo again',
94
+ ].join('\n'),
95
+ { maxBytes: flags.maxBytes ?? 0 },
96
+ );
97
+ return EXIT.FAIL;
98
+ }
99
+ // The tree is back to before the run, so a `check` from after the run no
100
+ // longer describes it — the line goes back to "no cache" (R48). A restored
101
+ // set always restored something, so there is nothing to gate on.
102
+ await invalidateCheckCache(root);
103
+ if (isJson(flags)) {
104
+ emitJson({ ok: true, command: 'undo', root, set: result.set, restored: result.restored, ...diffField(diffs, flags) });
105
+ return EXIT.OK;
106
+ }
107
+ emit(
108
+ [`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
109
+ { maxBytes: flags.maxBytes ?? 0 },
110
+ );
111
+ // After the report: the report says what, the diff shows it (R50).
112
+ emitDiffs(diffs, flags);
113
+ return EXIT.OK;
114
+ }