simt-dev 0.6.3 → 0.7.1

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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -0,0 +1,151 @@
1
+ // The command spec: one row per path this binary dispatches (review 0003 Q60).
2
+ //
3
+ // The list of commands existed in four places that disagreed - the dispatch map, a
4
+ // hand-written `--help` template, the completions table and the README - so a command
5
+ // could work and be undiscoverable, or be documented and absent. A row here is the one
6
+ // place a command is declared; `src/commands/index.mjs` derives its dispatchable paths
7
+ // and group defaults from it, and `test/spec.test.mjs` fails when the registry and the
8
+ // spec stop agreeing in either direction.
9
+ //
10
+ // The fields that only some consumers need (`tier`, `writes`, `args`, `flags`) arrive
11
+ // with the slices that read them: help (Q61), validation (Q62), MCP (Q66). A field no
12
+ // code reads is a field that drifts, and a wrong `writes` claim is worse than none.
13
+ //
14
+ // `defaultFor` marks the subcommand a bare group resolves to. It sits on the row that
15
+ // IS the default rather than on a separate map, so the reason for a default lives beside
16
+ // the command it names - `skills` alone indexes because the read is the safe default,
17
+ // `slice` alone starts because an id-less `start` fails before it writes anything.
18
+
19
+ /**
20
+ * @typedef {object} SpecRow
21
+ * @property {string} path the dispatch key: `status`, or `group subcommand`
22
+ * @property {string} summary one phrase, printed by help and by the generated docs
23
+ * @property {string} [defaultFor] the group whose bare form resolves to this row
24
+ * @property {string} [mcp] the MCP tool name that answers this row, when one does
25
+ */
26
+
27
+ /** @type {SpecRow[]} */
28
+ export const SPEC = [
29
+ { path: 'agent-hook', summary: 'the harness entry point: decide one tool call, or deliver the prompt packet', flags: ["diff","harness"], },
30
+ { path: 'agent-hooks install', summary: "write this repository's policies into each harness hooks file", writes: 'repo', flags: ["yes","diff","harness"], },
31
+ { path: 'agent-hooks status', summary: 'what each harness will run, and what is not trusted yet', defaultFor: 'agent-hooks', flags: ["diff","harness"], },
32
+ { path: 'agents install', summary: 'write the Simt-Agent into each harness agents file', writes: 'repo', flags: ["yes","diff","harness"], },
33
+ // A group like `agent-hooks`: `install` writes the agent file into each harness's own
34
+ // directory and `status` reports what a harness carries. The default is the read.
35
+ { path: 'agents status', summary: 'what each harness carries, and what is not installed yet', defaultFor: 'agents', flags: ["diff","harness"], },
36
+ { path: 'bench ablate', summary: 'measure one arm of the brief against its ablation', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
37
+ { path: 'bench critic', summary: 'score the review critic against the labelled bench set', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
38
+ { path: 'bench export', summary: 'write the sealed bench corpus as one signed document', writes: 'repo', flags: ["yes","all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
39
+ { path: 'bench import', summary: 'verify a sealed corpus and load it into the vault', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
40
+ { path: 'bench mine', summary: 'mine tasks out of a repository history', writes: 'repo', defaultFor: 'bench', flags: ["yes","all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
41
+ { path: 'bench probes', summary: 'run the deterministic probes against a workspace', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
42
+ { path: 'bench report', summary: 'summarise the runs recorded in the vault', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
43
+ { path: 'bench run', summary: 'run agents against the mined tasks', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
44
+ { path: 'bench variants', summary: 'run one task across its variant prompts', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
45
+ { path: 'brief', mcp: { name: 'brief', flags: ['budget', 'paths', 'task'] }, summary: 'the packet for a task: rules, skills and the files it names, within a budget', flags: ["budget","explain","paths","task"], },
46
+ { path: 'check', mcp: { name: 'check', flags: [] }, summary: 'report stale or missing generated regions and every check finding', flags: ["check","diff","dry-run"], },
47
+ { path: 'ci', summary: "refresh simt-dev's own .github workflow and Dependabot files", flags: ["check","diff"], },
48
+ { path: 'compare', mcp: { name: 'compare', flags: [] }, summary: 'the adoption and drift matrix across every sibling repository', flags: [], },
49
+ { path: 'completions', summary: 'print a bash, zsh or fish completion script generated from this spec', flags: [], },
50
+ { path: 'decide', summary: 'scaffold a numbered decision record with its frontmatter', writes: 'repo', flags: ["yes","check","diff","dry-run","paths","supersedes","task"], },
51
+ { path: 'distill', summary: 'turn a repository into a skill or rule proposal', flags: [], },
52
+ { path: 'doctor', mcp: { name: 'doctor', flags: [] }, summary: 'report what is wrong with this installation', writes: 'repo', flags: ["yes","diff","fix"], },
53
+ { path: 'evidence', summary: 'the runs recorded against the tree on disk', flags: [], },
54
+ { path: 'evidence check', summary: 'a commit message claiming a run this tree does not have', flags: [], },
55
+ { path: 'explain', summary: 'what one check finding code means, and how to fix it', flags: [], },
56
+ { path: 'gate', summary: 'the tests a change reaches, and only those', flags: ["changed"], },
57
+ { path: 'graph lint', summary: 'check the skill registry against the skills on disk', defaultFor: 'graph', flags: ["spec"], },
58
+ { path: 'harness detect', summary: 'which harness is installed, and what it carries', defaultFor: 'harness', flags: ["at","reader","table","user"], },
59
+ { path: 'harness loaders', summary: 'the instruction files each harness loads, and what they cost', flags: ["at","reader","table","user"], },
60
+ { path: 'hooks install', summary: 'install the shared dispatcher and report what it would run', writes: 'repo', flags: ["yes","diff","dry-run"], },
61
+ { path: 'hooks run', summary: "run one git event's gates now, as git would", writes: 'repo', flags: ["yes","diff","dry-run"], },
62
+ { path: 'hooks status', summary: 'what each git event routes to, and whether it is wired', defaultFor: 'hooks', flags: ["diff","dry-run"], },
63
+ { path: 'ignore', summary: 'render the one ignore list into every harness ignore file', flags: ["diff"], },
64
+ { path: 'impact', summary: 'who a change reaches: importers, their tests and the contract', flags: [], },
65
+ { path: 'index', summary: 'rebuild the local file index from git blob hashes', flags: ["allow-remote","embed"], },
66
+ { path: 'init', summary: 'adopt this repository: manifest, region, hooks and toolchain pins', writes: 'repo', flags: ["yes","accept-conflicts","backup","diff","surfaces"], },
67
+ { path: 'lease', summary: 'claim files for one session, or list the claims', flags: ["harness","session","ttl"], },
68
+ { path: 'lessons capture', summary: 'scan the ledger and history for new lesson candidates', flags: ["diff","expires","paths","task"], },
69
+ { path: 'lessons list', summary: 'deterministic lesson candidates and the promoted lessons', defaultFor: 'lessons', flags: ["diff","expires","paths","task"], },
70
+ { path: 'lessons review', summary: 'promote one lesson candidate into docs/lessons', flags: ["diff","expires","paths","task"], },
71
+ { path: 'local doctor', summary: 'the local model endpoints, and whether they answer', defaultFor: 'local', flags: ["allow-remote"], },
72
+ { path: 'map', summary: 'the files a task is most likely to touch, within a byte budget', flags: ["allow-remote","budget","embed","level","recall","task"], values: { level: ['outline', 'names', 'signatures'] }, },
73
+ { path: 'mcp', summary: 'serve the read-only MCP surface over stdio', flags: ["list"], },
74
+ // Plan 0003 R45. The default is the read: `pack` alone lints the working directory,
75
+ // the way `skills` alone indexes and `graph` alone lints.
76
+ { path: 'pack lint', summary: 'lint a rule pack in the working directory', defaultFor: 'pack', flags: ["diff","require-measured","source"], },
77
+ { path: 'pack new', summary: 'scaffold a rule pack', flags: ["diff","require-measured","source"], },
78
+ { path: 'pack publish', summary: 'publish a rule pack', flags: ["diff","require-measured","source"], },
79
+ { path: 'plan done', mcp: { name: 'plan_done', flags: ['state', 'test'] }, summary: 'mark one tracker row complete, with its SHA and its evidence', flags: ["diff","state","test"], },
80
+ { path: 'plan next', mcp: { name: 'plan_next', flags: [] }, summary: "one ready slice's section, or the one named", flags: ["diff","state","test"], },
81
+ { path: 'plan status', summary: 'count the tracker rows', defaultFor: 'plan', flags: ["diff","state","test"], },
82
+ { path: 'plugin', summary: 'generate a harness package from the same tables the hooks use', flags: ["diff","out"], },
83
+ { path: 'pr-body', mcp: { name: 'pr_body', flags: ['base'] }, summary: 'the pull request body: evidence, decisions, gate state and residual risk', flags: ["base"], },
84
+ { path: 'review', summary: 'review the staged diff against the estate rules', flags: [], },
85
+ { path: 'rules decisions', summary: 'every known decision, and which layer each came from', writes: 'repo', flags: ["yes","all"], },
86
+ { path: 'rules list', mcp: { name: 'rules_list', flags: [] }, summary: 'the rule catalogue, marked with what applies to this repository', defaultFor: 'rules', flags: ["all"], },
87
+ { path: 'rules show', summary: 'one rule body', flags: ["all"], },
88
+ { path: 'rules why', mcp: { name: 'rules_why', flags: [] }, summary: 'the decision that settled a rule, and whether it is still in force', flags: ["all"], },
89
+ { path: 'shared install', summary: 'install the shared rules into a reader home directory', writes: 'repo', flags: ["yes","diff"], },
90
+ // ADR-084. `shared` is the one group whose default writes nothing, and it is `plan`
91
+ // for the same reason `skills`'s is `index`: the read answers "what would this do, and
92
+ // where", and `install` - the subcommand that touches a reader's home directory - is
93
+ // the one that needs `--yes` before it does anything.
94
+ { path: 'shared plan', summary: 'what the shared install would write, and where', writes: 'repo', defaultFor: 'shared', flags: ["yes","diff"], },
95
+ { path: 'skills brief', mcp: { name: 'skills_brief', flags: ['all', 'task'] }, summary: 'the packet for a task, read from the skill registry', flags: ["all", "plan", "task"], },
96
+ { path: 'skills compile', summary: 'compile a skill to the Agent Skills standard', flags: [], },
97
+ { path: 'skills eval', summary: 'score the skill registry against transcripts', flags: ["all","plan"], },
98
+ { path: 'skills find', mcp: { name: 'skills_find', flags: [] }, summary: 'skills that match a query', flags: ["all","plan"], },
99
+ { path: 'skills index', mcp: { name: 'skills_index', flags: [] }, summary: 'rebuild the skill index', defaultFor: 'skills', flags: ["all","plan"], },
100
+ { path: 'skills install', summary: 'install skills into the workspace', writes: 'repo', flags: ["yes","diff","from","variant"], },
101
+ { path: 'skills show', mcp: { name: 'skills_show', flags: [] }, summary: 'one skill packet', flags: ["all","plan"], },
102
+ { path: 'skills why', mcp: { name: 'skills_why', flags: [] }, summary: 'which skill covers a task', flags: ["all","plan"], },
103
+ { path: 'slice abandon', summary: 'remove a slice worktree, delete its branch, and return the row', flags: ["session","test","type"], },
104
+ { path: 'slice finish', summary: 'run the gates, commit the slice and record its evidence', writes: 'repo', flags: ["yes","session","test","type"], },
105
+ // Plan 0003 R53. Both halves write, so a bare `slice` has no read to default to.
106
+ // `start` is the default because with the id it is missing it fails with a usage error
107
+ // before anything is created - the one default here that cannot mutate.
108
+ { path: 'slice start', summary: 'give a slice its own worktree, branch and brief', defaultFor: 'slice', flags: ["session","test","type"], },
109
+ { path: 'status', summary: 'gate state from the last check', flags: ["line"], },
110
+ { path: 'sync', summary: 're-render the generated regions from the rule content', writes: 'repo', flags: ["yes","check","diff","dry-run"], },
111
+ { path: 'tests', summary: 'the tests that exercise a file', flags: ["cochange"], },
112
+ { path: 'tokens', summary: 'count tokens per instruction file and per skill', flags: ["repos","session"], },
113
+ { path: 'undo', summary: 'restore the last journaled set of writes, if untouched', writes: 'repo', flags: ["yes","diff"], },
114
+ { path: 'upgrade', summary: 'replace this installation with the pinned release', writes: 'repo', flags: ["yes","all","diff","workspace"], },
115
+ { path: 'why', summary: 'what governs a file: decisions, rules, skills, gates and lessons', flags: [], },
116
+ ];
117
+
118
+
119
+ /**
120
+ * Which tier a command belongs to, decided by its group: the commands a reader reaches
121
+ * for while working are core and appear in `--help`; the rest are advanced and appear
122
+ * under `help --all`. A tier is a presentation fact, not a permission, so a command
123
+ * cannot be missing from both pages - `test/help.test.mjs` checks that every path is on
124
+ * one of them (review 0003 Q61).
125
+ */
126
+ const CORE_GROUPS = new Set(['brief', 'check', 'gate', 'graph', 'hooks', 'impact', 'init', 'map', 'plan', 'rules', 'skills', 'slice', 'status', 'sync', 'tests', 'why']);
127
+
128
+ /** The tier a path is listed under: `core` or `advanced`. */
129
+ export function tierOf(path) {
130
+ return CORE_GROUPS.has(path.split(' ')[0]) ? 'core' : 'advanced';
131
+ }
132
+
133
+ /**
134
+ * The tools that answer a question no command asks.
135
+ *
136
+ * `federation_query` reads the federation config and consults a sibling repository
137
+ * directly (src/federation/query.mjs). There is no CLI verb for it, so giving it a spec
138
+ * row would advertise a command this binary cannot dispatch; leaving it undeclared is
139
+ * how a tool list and a command list drift apart. It is listed here with the reason
140
+ * instead, and test/mcp-spec.test.mjs accepts a tool without a row only when this table
141
+ * says why.
142
+ */
143
+ export const MCP_ONLY = [
144
+ { tool: 'federation_query', why: 'a transport-only read: no command in this binary asks a sibling repository a question' },
145
+ ];
146
+
147
+ /** The dispatchable paths, sorted - the answer to "what can this binary do". */
148
+ export const SPEC_PATHS = SPEC.map((row) => row.path).sort();
149
+
150
+ /** group -> the subcommand its bare form resolves to. */
151
+ export const GROUP_DEFAULTS = new Map(SPEC.filter((row) => row.defaultFor !== undefined).map((row) => [row.defaultFor, row.path.split(' ')[1]]));
@@ -10,12 +10,13 @@
10
10
  // only mode this command has is `--line`, and running something else by
11
11
  // default would invent a second answer to what the status is.
12
12
 
13
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
14
14
  import { CLI } from '../name.mjs';
15
15
  import { currentHead, readCheckCache, renderStatusLine } from '../status.mjs';
16
+ import { commandRoot } from '../repo-root.mjs';
16
17
 
17
18
  export async function statusCommand(args, flags) {
18
- const root = flags.root ?? args[0] ?? process.cwd();
19
+ const root = commandRoot(flags, args);
19
20
  if (flags.line !== true) {
20
21
  return fail('status needs --line', EXIT.USAGE, `run: ${CLI} status --line --root ${root}`);
21
22
  }
@@ -25,11 +26,7 @@ export async function statusCommand(args, flags) {
25
26
  // Informational, not a failure: a repository that never ran `check` — or
26
27
  // one a writer just invalidated — has no state to report, and a prompt
27
28
  // 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');
29
+ present({ ok: true, command: 'status', root, line: null, cached: false, stale: false }, () => 'simt no cache - run check', flags);
33
30
  return EXIT.OK;
34
31
  }
35
32
 
@@ -19,22 +19,23 @@
19
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
- import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
22
+ import { repoContext } from '../context.mjs';
23
+ import { plan, checkTargets, checkTarget, select, pointerTargets, pointerState } from '../projection/project.mjs';
23
24
  import { fingerprint } from '../resolve/fingerprint.mjs';
24
25
  import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText, manifestPath } from '../projection/manifest.mjs';
25
26
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
26
27
  import { readCapability } from '../hooks/capability.mjs';
27
28
  import { surfacesLine } from '../projection/surfaces.mjs';
28
- import { CLI } from '../name.mjs';
29
- import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
+ import { CLI, LOCK_FILE, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
30
30
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
31
31
  import { resolvePacks } from '../packs.mjs';
32
32
  import { commitJournal, journaledWriteFile } from '../journal.mjs';
33
33
  import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
34
- import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
34
+ import { computeLock, readLock } from '../projection/lock.mjs';
35
35
  import { runDeclaredChecks } from '../kernel/checks.mjs';
36
- import { DECISIONS_DIR } from '../decisions/index.mjs';
36
+ import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
37
37
  import { lintDecisions } from '../memory/decide.mjs';
38
+ import { commandRoot } from '../repo-root.mjs';
38
39
 
39
40
  /** Findings `sync` reports and does not fail on.
40
41
  *
@@ -64,6 +65,12 @@ export const SYNC_NON_BLOCKING = new Set([
64
65
  'budget-cut',
65
66
  'file-lines-warn',
66
67
  'check-unbuilt',
68
+ // Review 0003 Q28: the tsconfig strictness findings. The base file is repo-owned —
69
+ // init no longer writes it, so neither can `sync` — and both findings' remedies are
70
+ // a decision about the repo's own compiler policy, not a missing file. Blocking on
71
+ // them would fail every fresh TypeScript adoption over a file the tool does not write.
72
+ 'inert',
73
+ 'inconsistent',
67
74
  ]);
68
75
 
69
76
  /**
@@ -101,10 +108,24 @@ export const SYNC_NON_BLOCKING = new Set([
101
108
  // (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
102
109
  // remedy is a decision about the number — and a cut the repository asked for by declaring
103
110
  // a budget is the tool working, not a repository in need of a file.
104
- export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget', 'budget-cut', 'file-lines-warn', 'check-unbuilt']);
111
+ // `inert` and `inconsistent` are here for review 0003 Q28: the base file is repo-owned,
112
+ // so the gate reports the state of the repo's own compiler policy without failing it.
113
+ // A fresh TypeScript adoption has neither file in a state the tool can repair, and a
114
+ // gate that reddens on a policy decision is one the repo satisfies by choosing, not by
115
+ // running a command.
116
+ export const CHECK_NON_BLOCKING = new Set([
117
+ 'orphaned',
118
+ 'pack-unpinned',
119
+ 'budget',
120
+ 'budget-cut',
121
+ 'file-lines-warn',
122
+ 'check-unbuilt',
123
+ 'inert',
124
+ 'inconsistent',
125
+ ]);
105
126
 
106
127
  export async function syncCommand(positional, flags) {
107
- const root = flags.root ?? positional[0] ?? process.cwd();
128
+ const root = commandRoot(flags, positional);
108
129
  const checkOnly = flags.check === true;
109
130
  const mode = writeMode(flags);
110
131
 
@@ -153,6 +174,10 @@ export async function syncCommand(positional, flags) {
153
174
  );
154
175
  }
155
176
 
177
+ // A dangling cross-reference does not stop the projection: the region renders, and one
178
+ // unreachable reference is a defect to report, not a reason to withhold the rest.
179
+ for (const p of result.crossRefs ?? []) note(`PROBLEM ${p}`);
180
+
156
181
  const broken = result.targets.filter((t) => t.action === 'malformed');
157
182
  if (broken.length > 0) {
158
183
  const first = broken[0];
@@ -307,23 +332,13 @@ export async function syncCommand(positional, flags) {
307
332
  // mkdir; `sync` did not, so the first `sync` after a surface was added failed with
308
333
  // ENOENT on the second target and had already written the first. The parent is
309
334
  // derived from the target, never from the surface table, so a future surface with a
310
- // deeper path needs no change here.
335
+ // deeper path needs no change here. The lock is one of these targets (Q29): it
336
+ // reaches this loop like every other file, so a deleted lock is a write the plan
337
+ // shows and the journal records.
311
338
  await mkdir(dirname(t.path), { recursive: true });
312
339
  await journaledWriteFile(root, journal, t.path, t.text);
313
340
  note(`wrote ${t.file}`);
314
341
  }
315
- const lock = await computeLock(root, flags);
316
- const lockText = `${JSON.stringify(lock, null, 2)}\n`;
317
- let previousLock = null;
318
- try {
319
- previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
320
- } catch (error) {
321
- if (error.code !== 'ENOENT') throw error;
322
- }
323
- if (previousLock !== lockText) {
324
- await journaledWriteFile(root, journal, join(root, LOCK_FILE), lockText);
325
- note(`wrote ${LOCK_FILE}`);
326
- }
327
342
  await commitJournal(root, 'sync', journal);
328
343
  // The regions moved, so the last `check` no longer describes this tree.
329
344
  // Invalidated rather than refreshed: `sync` does not run gates, checkers
@@ -396,7 +411,7 @@ export async function syncCommand(positional, flags) {
396
411
  : `would write ${changed.length + pointersAdded.length + (manifestChanged ? 1 : 0)} file(s); apply with: ${CLI} sync --yes`,
397
412
  );
398
413
  }
399
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
414
+ emit(lines.join('\n'));
400
415
  // After the report: the report says what, the diff shows it (R50).
401
416
  emitDiffs(diffs, flags);
402
417
  }
@@ -531,8 +546,9 @@ async function gateCwdFindings(root, manifest) {
531
546
  // reports 77. The command below renders these facts for terminals; the tool
532
547
  // returns them as JSON. The CLI keeps its field names; the tool adopts them.
533
548
  export async function checkRepo(root, opts = {}) {
534
- const findings = await checkTargets(root, opts);
535
- const rules = await loadRules(root);
549
+ const ctx = opts.ctx ?? repoContext(root);
550
+ const findings = await checkTargets(root, { ...opts, ctx });
551
+ const rules = await ctx.rules();
536
552
 
537
553
  // A rule file with no id would be projected without a traceable source, so it
538
554
  // is a gate failure rather than a warning: `rules why` could not resolve it.
@@ -555,7 +571,7 @@ export async function checkRepo(root, opts = {}) {
555
571
  // A declared gate this repo cannot run fails closed on every commit once hooks are
556
572
  // installed. Reported here rather than left to `hooks status`, so the repo is red
557
573
  // before anyone installs anything rather than after.
558
- const { manifest, exists } = await readManifest(root);
574
+ const { manifest, exists } = await ctx.manifest();
559
575
  // The gates slot, counted — not just reported — so the line can print run/total.
560
576
  // A cwd finding names a declared gate that will not run, so it counts with the
561
577
  // unrunnable; an unknown-gate-cwd names no declared gate, so it does not.
@@ -590,24 +606,26 @@ export async function checkRepo(root, opts = {}) {
590
606
  // hand either: the next `init` puts simt-dev's own bytes back and the formatter
591
607
  // takes them away again. Reporting it here is what stops that being a surprise
592
608
  // discovered at upgrade time.
593
- const { stack } = await fingerprint(root);
594
- const drift = await manifestDrift(root, stack, await provenanceDigest());
609
+ const { stack } = await ctx.fingerprint();
610
+ const drift = await manifestDrift(root, stack, await ctx.digest());
595
611
  if (drift) findings.push(drift);
596
612
  }
597
613
  if (opts.frozen) findings.push(...(await frozenFindings(root, opts)));
598
614
  // The checkers a selected rule declared (ADR-088). They read the rule source,
599
615
  // not the folded region, so a line that left the prompt is still the line that runs.
600
- const fp = await fingerprint(root);
616
+ const fp = await ctx.fingerprint();
601
617
  const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
602
618
  findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
603
619
 
604
- const decisionsDir = join(root, 'docs', 'decisions');
605
- const decisionLint = await lintDecisions(decisionsDir, {
606
- allowHistorical: resolve(decisionsDir) === DECISIONS_DIR,
620
+ // Q55: one resolution shared with `decide --check`, so the two commands
621
+ // cannot disagree about whose records these are or which are historical.
622
+ const relDecisionsDir = await decisionsDir(root);
623
+ const decisionLint = await lintDecisions(join(root, relDecisionsDir), {
624
+ historicalCutoff: await decisionsCutoff(root),
607
625
  });
608
626
  findings.push(
609
627
  ...decisionLint.findings.map((f) => ({
610
- file: ['docs', 'decisions', f.file].join('/'),
628
+ file: [relDecisionsDir, f.file].join('/'),
611
629
  status: f.status,
612
630
  detail: f.detail,
613
631
  remedy: f.remedy,
@@ -637,7 +655,7 @@ export async function checkRepo(root, opts = {}) {
637
655
  }
638
656
 
639
657
  export async function checkCommand(positional, flags) {
640
- const root = flags.root ?? positional[0] ?? process.cwd();
658
+ const root = commandRoot(flags, positional);
641
659
  const { ok, findings } = await checkRepo(root, flags);
642
660
  const payload = {
643
661
  ok,
@@ -663,7 +681,6 @@ export async function checkCommand(positional, flags) {
663
681
  return f.remedy ? [head, `${' '.repeat(11)}remedy: ${f.remedy}`] : [head];
664
682
  })
665
683
  .join('\n'),
666
- { maxBytes: flags.maxBytes ?? 0 },
667
684
  );
668
685
  return ok ? EXIT.OK : EXIT.FAIL;
669
686
  }
@@ -10,11 +10,12 @@ import { readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { promisify } from 'node:util';
12
12
 
13
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
+ import { EXIT, emit, emitJson, fail, isJson, present } 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
+ import { commandRoot } from '../repo-root.mjs';
18
19
 
19
20
  const exec = promisify(execFile);
20
21
 
@@ -31,7 +32,7 @@ const COVERAGE_PATHS = [
31
32
 
32
33
  export async function testsCommand(args, flags) {
33
34
  if (args.length > 1) return fail(`unknown tests argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} tests [path]`);
34
- const root = flags.root ?? process.cwd();
35
+ const root = commandRoot(flags);
35
36
  const loaded = await loadFiles(root);
36
37
  if (loaded.error) return loaded.error;
37
38
  const { files } = loaded;
@@ -58,11 +59,11 @@ export async function testsCommand(args, flags) {
58
59
  const tests = testsFor(target, edges, { cochange: flags.cochange === true });
59
60
  const payload = { ok: true, command: 'tests', root, path: target, tests };
60
61
  if (isJson(flags)) {
61
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
62
+ emitJson(payload);
62
63
  return EXIT.OK;
63
64
  }
64
65
  const lines = [`tests ${tests.length}`, ...tests];
65
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
66
+ emit(lines.join('\n'));
66
67
  return EXIT.OK;
67
68
  }
68
69
 
@@ -74,17 +75,16 @@ function summarise(root, flags, edges) {
74
75
  else if (edge.kind === 'cochange') cochange += 1;
75
76
  }
76
77
  const payload = { ok: true, command: 'tests', root, test, cochange };
77
- if (isJson(flags)) {
78
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
79
- return EXIT.OK;
80
- }
81
- emit(`test ${test}\ncochange ${cochange}`, { maxBytes: flags.maxBytes ?? 0 });
78
+ present(payload, () => `test ${test}\ncochange ${cochange}`, flags);
82
79
  return EXIT.OK;
83
80
  }
84
81
 
85
82
  async function loadFiles(root) {
86
83
  let files = await readFiles(root);
87
- if (files.length === 0) {
84
+ // An empty index has nothing to answer from; a stale one has yesterday's
85
+ // tree (review 0003 Q42). Either way the refresh is incremental, so a
86
+ // current repository pays for one `git ls-files -s`, not a rebuild.
87
+ if (files.length === 0 || (await isIndexStale(root))) {
88
88
  try {
89
89
  await reindex(root);
90
90
  } catch (error) {
@@ -73,7 +73,7 @@ export async function tokensCommand(positional, flags) {
73
73
  `tool output ${plain(session.toolOutput.bytes)} B ~${plain(session.toolOutput.tokens)} tokens (${plain(session.toolOutput.share)}%)`,
74
74
  `total ${plain(session.total.bytes)} B ~${plain(session.total.tokens)} tokens`,
75
75
  ];
76
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
76
+ emit(lines.join('\n'));
77
77
  return EXIT.OK;
78
78
  }
79
79
 
@@ -193,6 +193,6 @@ export async function tokensCommand(positional, flags) {
193
193
  `mcp tools/list ${plain(mcp.bytes)} B of ${plain(mcp.tools)} tools (the same for every repository, so it is not in any total)`,
194
194
  );
195
195
 
196
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
196
+ emit(lines.join('\n'));
197
197
  return EXIT.OK;
198
198
  }
@@ -1,62 +1,66 @@
1
1
  // `simt undo [--yes] [--json]`
2
2
  //
3
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.
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.
7
8
  //
8
9
  // The safety is the journal's, not the flag's: when any file in the set no
9
10
  // longer matches the bytes the run left, the whole undo is refused — restoring
10
11
  // the rest around a hand edit would silently merge two authors' intentions.
11
12
  // Like every other writer this command previews without `--yes`.
12
13
 
13
- import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
14
+ import { EXIT, emit, emitJson, isJson, writeMode, present } from '../output/index.mjs';
14
15
  import { CLI } from '../name.mjs';
15
- import { listJournalSets, readJournalSet, undoLastJournalSet } from '../journal.mjs';
16
+ import { beforeText, entryLabel, listJournalSets, readGitValue, readJournalSet, undoLastJournalSet } from '../journal.mjs';
16
17
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
17
18
  import { join } from 'node:path';
18
19
  import { invalidateCheckCache } from '../status.mjs';
20
+ import { commandRoot } from '../repo-root.mjs';
19
21
 
20
22
  export async function undoCommand(positional, flags) {
21
- const root = flags.root ?? positional[0] ?? process.cwd();
23
+ const root = commandRoot(flags, positional);
22
24
  const mode = writeMode(flags);
23
25
 
24
26
  const [id] = await listJournalSets(root);
25
27
  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 });
28
+ present({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply }, () => 'nothing to undo: no journaled writes', flags);
31
29
  return EXIT.OK;
32
30
  }
33
31
 
34
32
  const set = await readJournalSet(root, id);
35
- const paths = set.entries.map((e) => e.path);
33
+ const labels = set.entries.map(entryLabel);
36
34
  // 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).
35
+ // would put back. A null before is a file the run created (or a key it
36
+ // set from unset), so the restore deletes it — the after side is empty
37
+ // (R50).
39
38
  const diffs =
40
39
  flags.diff === true
41
40
  ? 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
- })),
41
+ set.entries.map(async (e) => {
42
+ if (e.kind === 'git-config') {
43
+ return { path: entryLabel(e), before: await readGitValue(root, e.key), after: e.before ?? null };
44
+ }
45
+ return { path: e.path, before: await readBefore(join(root, e.path)), after: beforeText(e) };
46
+ }),
47
47
  )
48
48
  : [];
49
49
 
50
50
  if (!mode.apply) {
51
51
  if (isJson(flags)) {
52
- emitJson({ ok: true, command: 'undo', root, set: id, restores: paths, dryRun: true, ...diffField(diffs, flags) });
52
+ emitJson({ ok: true, command: 'undo', root, set: id, restores: labels, dryRun: true, ...diffField(diffs, flags) });
53
53
  return EXIT.OK;
54
54
  }
55
+ const files = set.entries.filter((e) => e.kind !== 'git-config').length;
56
+ const keys = set.entries.length - files;
55
57
  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 },
58
+ [
59
+ `last write ${set.command} (${id})`,
60
+ ...labels.map((p) => `restore ${p}`),
61
+ '',
62
+ `would restore ${files} file(s)${keys > 0 ? ` and ${keys} git config key(s)` : ''}; apply with: ${CLI} undo --yes`,
63
+ ].join('\n'),
60
64
  );
61
65
  // After the report: the report says what, the diff shows it (R50).
62
66
  emitDiffs(diffs, flags);
@@ -65,11 +69,7 @@ export async function undoCommand(positional, flags) {
65
69
 
66
70
  const result = await undoLastJournalSet(root);
67
71
  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 });
72
+ present({ ok: true, command: 'undo', root, set: null, restored: [] }, () => 'nothing to undo: no journaled writes', flags);
73
73
  return EXIT.OK;
74
74
  }
75
75
  if (result.refused) {
@@ -84,7 +84,6 @@ export async function undoCommand(positional, flags) {
84
84
  '',
85
85
  'nothing was restored; re-run the writing command or merge by hand, then undo again',
86
86
  ].join('\n'),
87
- { maxBytes: flags.maxBytes ?? 0 },
88
87
  );
89
88
  return EXIT.FAIL;
90
89
  }
@@ -98,7 +97,6 @@ export async function undoCommand(positional, flags) {
98
97
  }
99
98
  emit(
100
99
  [`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
101
- { maxBytes: flags.maxBytes ?? 0 },
102
100
  );
103
101
  // After the report: the report says what, the diff shows it (R50).
104
102
  emitDiffs(diffs, flags);