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,304 @@
1
+ // `simt lease [<paths>...]`: claim files for one session, or read the claims (R54).
2
+ // `lease release` and `lease renew` end and extend the holder's own claims: the
3
+ // denial tells a blocked session to ask the holder to release the path, so the
4
+ // holder needs a command that does it (review 0003 Q20).
5
+ //
6
+ // The claim is the half of ADR-101 a session does by hand: `lease src/a.mjs --session
7
+ // alpha` records that alpha is working on the path until a TTL expires, the pre-edit
8
+ // hook reads the same file to refuse anyone else, and a bare `lease` lists what is
9
+ // currently held — because the first question a blocked session asks is "who has
10
+ // this", and opening a JSON file by hand is not an answer the tool should outsource.
11
+ //
12
+ // Two refusals are deliberate and both come from ADR-101: a path another session
13
+ // still holds is not taken (stealing is what the TTL is for, and the error names the
14
+ // holder and the expiry), and a claim file that will not parse is not overwritten
15
+ // (the hook fails closed on that same file, so writing over it would erase the very
16
+ // claims the hook is about to enforce while telling the author it fixed something).
17
+
18
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
19
+ import {
20
+ DEFAULT_TTL_SECONDS,
21
+ activeLeases,
22
+ canonicalLeasePath,
23
+ readLeases,
24
+ withLeaseLock,
25
+ writeLeases,
26
+ } from '../lease.mjs';
27
+ import { coreIgnoreCase } from '../repo-root.mjs';
28
+ import { SESSION_ENV } from '../agenthooks/policy.mjs';
29
+ import { CLI } from '../name.mjs';
30
+
31
+ const ISO = (ms) => new Date(ms).toISOString();
32
+
33
+ export async function leaseCommand(args, flags) {
34
+ const root = flags.root ?? process.cwd();
35
+ const [sub, ...rest] = args;
36
+ // The denial tells a blocked session to ask the holder to release the path,
37
+ // so releasing is a subcommand, not a missing flag (review 0003 Q20).
38
+ if (sub === 'release') return releaseLeases(root, rest, flags);
39
+ if (sub === 'renew') return renewLeases(root, rest, flags);
40
+
41
+ // A session or a TTL with no paths is a claim about nothing; the parser would
42
+ // otherwise hand back a list the caller never asked to see.
43
+ if (args.length === 0) {
44
+ if (flags.session !== undefined || flags.ttl !== undefined) {
45
+ return fail(`lease without paths is a list, so it takes neither --session nor --ttl`, EXIT.USAGE, `run: ${CLI} lease <paths> --session <id>`);
46
+ }
47
+ return listLeases(root, flags);
48
+ }
49
+
50
+ const session = String(flags.session ?? process.env[SESSION_ENV] ?? '');
51
+ if (session === '') {
52
+ return fail(`lease needs a session to hold the claim`, EXIT.USAGE, `run: ${CLI} lease <paths> --session <id> (or set ${SESSION_ENV})`);
53
+ }
54
+ const ttl = flags.ttl === undefined ? DEFAULT_TTL_SECONDS : Number(flags.ttl);
55
+ if (!Number.isInteger(ttl) || ttl < 1) {
56
+ return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease <paths> --ttl 900`);
57
+ }
58
+ const harness = typeof flags.harness === 'string' && flags.harness !== '' ? flags.harness : null;
59
+
60
+ const wanted = [];
61
+ const seen = new Set();
62
+ // The claim is stored in the one spelling the hook's comparison will produce:
63
+ // a claim taken as `src/Keep.mjs` and an edit that spells `src/keep.mjs` must
64
+ // be the same claim on a case-insensitive volume, so the stored path is
65
+ // canonical at write time, not at read time.
66
+ const ignoreCase = coreIgnoreCase(root);
67
+ for (const arg of args) {
68
+ const rel = canonicalLeasePath(root, arg, { ignoreCase });
69
+ if (rel === null) {
70
+ return fail(`${arg} is not a path inside the repository`, EXIT.USAGE, `leases are repository-relative: run: ${CLI} lease <path> --session ${session}`);
71
+ }
72
+ if (!seen.has(rel)) {
73
+ seen.add(rel);
74
+ wanted.push(rel);
75
+ }
76
+ }
77
+
78
+ // Older claim files hold the pre-canonical spelling, so a stored path is
79
+ // canonicalised before it is compared: a claim written before the resolver
80
+ // existed must still conflict with a claim taken for the same file.
81
+ const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
82
+ const newClaim = (expiresAt) =>
83
+ wanted.map((path) => ({ path, session, expiresAt, ...(harness === null ? {} : { harness }) }));
84
+
85
+ const mode = writeMode(flags);
86
+ if (!mode.apply) {
87
+ const mine = newClaim(Date.now() + ttl * 1000);
88
+ const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
89
+ if (isJson(flags)) {
90
+ emitJson({ ok: true, command: 'lease', root, dryRun: true, session, ttl, leases: mine, remedy: apply });
91
+ return EXIT.OK;
92
+ }
93
+ const lines = [
94
+ `repo ${root}`,
95
+ `session ${session}`,
96
+ `ttl ${ttl}s`,
97
+ ...mine.map((l) => `would lease ${l.path} until ${ISO(l.expiresAt)}`),
98
+ `apply ${apply}`,
99
+ ];
100
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
101
+ return EXIT.OK;
102
+ }
103
+
104
+ // The read-modify-write happens under the lock: twelve concurrent claims
105
+ // stored seven before this, because every claimant read the same file and
106
+ // each wrote back only what it had seen (review 0003 Q20).
107
+ const claimed = await withLeaseLock(root, async () => {
108
+ const read = await readLeases(root);
109
+ if (read.error !== null) return { error: read.error, path: read.path };
110
+ const held = activeLeases(read);
111
+ const conflicts = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
112
+ if (conflicts.length > 0) return { conflicts };
113
+ // Keep every other live claim, refresh ours, and let an expired one fall out:
114
+ // the write is also the only sweep the file ever gets.
115
+ const others = held.filter((l) => !seen.has(keyOf(l)));
116
+ const mine = newClaim(Date.now() + ttl * 1000);
117
+ const path = await writeLeases(root, [...others, ...mine]);
118
+ return { mine, path };
119
+ });
120
+ if (claimed.error) {
121
+ return fail(`${claimed.error}`, EXIT.FAIL, `fix or remove ${claimed.path}; the pre-edit hook fails closed on it too`);
122
+ }
123
+ if (claimed.conflicts) {
124
+ return fail(
125
+ `${claimed.conflicts.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; ')}`,
126
+ EXIT.FAIL,
127
+ `wait for the TTL or ask the holder to release it (${CLI} lease release <paths> --session ${session}); run: ${CLI} lease to see every claim`,
128
+ );
129
+ }
130
+ const { mine, path } = claimed;
131
+
132
+ if (isJson(flags)) {
133
+ emitJson({ ok: true, command: 'lease', root, session, ttl, dryRun: false, leases: mine, file: path });
134
+ return EXIT.OK;
135
+ }
136
+ const lines = [
137
+ `repo ${root}`,
138
+ `session ${session}`,
139
+ `ttl ${ttl}s`,
140
+ ...mine.map((l) => `LEASED ${l.path} until ${ISO(l.expiresAt)}`),
141
+ ];
142
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
143
+ return EXIT.OK;
144
+ }
145
+
146
+ /** Resolve release/renew targets to canonical paths, or the usage failure. */
147
+ function resolveTargets(root, args, session, verb) {
148
+ if (args.length === 0) {
149
+ return { error: fail(`lease ${verb} needs paths`, EXIT.USAGE, `run: ${CLI} lease ${verb} <paths> --session <id>`) };
150
+ }
151
+ const ignoreCase = coreIgnoreCase(root);
152
+ const wanted = [];
153
+ const seen = new Set();
154
+ for (const arg of args) {
155
+ const rel = canonicalLeasePath(root, arg, { ignoreCase });
156
+ if (rel === null) {
157
+ return { error: fail(`${arg} is not a path inside the repository`, EXIT.USAGE, `leases are repository-relative`) };
158
+ }
159
+ if (!seen.has(rel)) {
160
+ seen.add(rel);
161
+ wanted.push(rel);
162
+ }
163
+ }
164
+ return { wanted, seen, ignoreCase };
165
+ }
166
+
167
+ function claimSession(flags) {
168
+ const session = String(flags.session ?? process.env[SESSION_ENV] ?? '');
169
+ if (session === '') {
170
+ return { error: fail(`lease needs a session to hold the claim`, EXIT.USAGE, `run: ${CLI} lease <paths> --session <id> (or set ${SESSION_ENV})`) };
171
+ }
172
+ return { session };
173
+ }
174
+
175
+ async function releaseLeases(root, args, flags) {
176
+ const { session, error: sessionError } = claimSession(flags);
177
+ if (sessionError !== undefined) return sessionError;
178
+ const resolved = resolveTargets(root, args, session, 'release');
179
+ if (resolved.error !== undefined) return resolved.error;
180
+ const { wanted, seen, ignoreCase } = resolved;
181
+ const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
182
+ const mode = writeMode(flags);
183
+
184
+ const released = await withLeaseLock(root, async () => {
185
+ const read = await readLeases(root);
186
+ if (read.error !== null) return { error: read.error, path: read.path };
187
+ const held = activeLeases(read);
188
+ const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
189
+ if (foreign.length > 0) {
190
+ return {
191
+ conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
192
+ };
193
+ }
194
+ const dropped = held.filter((l) => seen.has(keyOf(l)));
195
+ if (!mode.apply) return { preview: dropped };
196
+ const kept = held.filter((l) => !seen.has(keyOf(l)));
197
+ const path = await writeLeases(root, kept);
198
+ return { dropped, path };
199
+ });
200
+ if (released.error) {
201
+ return fail(`${released.error}`, EXIT.FAIL, `fix or remove ${released.path}`);
202
+ }
203
+ if (released.conflicts) {
204
+ return fail(`${released.conflicts}`, EXIT.FAIL, `only the holder releases a claim; wait for the TTL or ask ${session} to run: ${CLI} lease release`);
205
+ }
206
+ if (!mode.apply) {
207
+ const lines = [
208
+ `repo ${root}`,
209
+ `session ${session}`,
210
+ ...released.preview.map((l) => `would release ${l.path} (held by ${l.session})`),
211
+ ...(released.preview.length === 0 ? ['(no active claims on those paths)'] : []),
212
+ `apply run: ${CLI} lease release ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`,
213
+ ];
214
+ if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: true, session, leases: released.preview });
215
+ else emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
216
+ return EXIT.OK;
217
+ }
218
+ for (const l of released.dropped) note(`note released ${l.path} (was held by ${l.session})`);
219
+ if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: false, session, leases: released.dropped });
220
+ else {
221
+ emit(
222
+ [
223
+ `repo ${root}`,
224
+ `session ${session}`,
225
+ ...released.dropped.map((l) => `RELEASED ${l.path}`),
226
+ ...(released.dropped.length === 0 ? ['(no active claims on those paths)'] : []),
227
+ ].join('\n'),
228
+ { maxBytes: flags.maxBytes ?? 0 },
229
+ );
230
+ }
231
+ return EXIT.OK;
232
+ }
233
+
234
+ async function renewLeases(root, args, flags) {
235
+ const { session, error: sessionError } = claimSession(flags);
236
+ if (sessionError !== undefined) return sessionError;
237
+ const resolved = resolveTargets(root, args, session, 'renew');
238
+ if (resolved.error !== undefined) return resolved.error;
239
+ const { wanted, seen, ignoreCase } = resolved;
240
+ const ttl = flags.ttl === undefined ? DEFAULT_TTL_SECONDS : Number(flags.ttl);
241
+ if (!Number.isInteger(ttl) || ttl < 1) {
242
+ return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease renew <paths> --ttl 900`);
243
+ }
244
+ const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
245
+ const mode = writeMode(flags);
246
+
247
+ const renewed = await withLeaseLock(root, async () => {
248
+ const read = await readLeases(root);
249
+ if (read.error !== null) return { error: read.error, path: read.path };
250
+ const held = activeLeases(read);
251
+ const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
252
+ if (foreign.length > 0) {
253
+ return {
254
+ conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
255
+ };
256
+ }
257
+ const expiresAt = Date.now() + ttl * 1000;
258
+ const mine = held.map((l) => (seen.has(keyOf(l)) ? { ...l, expiresAt } : l));
259
+ const touched = held.filter((l) => seen.has(keyOf(l)));
260
+ if (!mode.apply) return { preview: touched.map((l) => ({ ...l, expiresAt })) };
261
+ const path = await writeLeases(root, mine);
262
+ return { touched: touched.map((l) => ({ ...l, expiresAt })), path };
263
+ });
264
+ if (renewed.error) {
265
+ return fail(`${renewed.error}`, EXIT.FAIL, `fix or remove ${renewed.path}`);
266
+ }
267
+ if (renewed.conflicts) {
268
+ return fail(`${renewed.conflicts}`, EXIT.FAIL, `only the holder renews a claim; run: ${CLI} lease to see every claim`);
269
+ }
270
+ const rows = mode.apply ? renewed.touched : renewed.preview;
271
+ if (isJson(flags)) emitJson({ ok: true, command: 'lease renew', root, dryRun: !mode.apply, session, ttl, leases: rows });
272
+ else {
273
+ emit(
274
+ [
275
+ `repo ${root}`,
276
+ `session ${session}`,
277
+ `ttl ${ttl}s`,
278
+ ...rows.map((l) => `${mode.apply ? 'RENEWED ' : 'would renew '} ${l.path} until ${ISO(l.expiresAt)}`),
279
+ ...(rows.length === 0 ? ['(no active claims on those paths)'] : []),
280
+ ].join('\n'),
281
+ { maxBytes: flags.maxBytes ?? 0 },
282
+ );
283
+ }
284
+ return EXIT.OK;
285
+ }
286
+
287
+ async function listLeases(root, flags) {
288
+ const read = await readLeases(root);
289
+ if (read.error !== null) {
290
+ return fail(`${read.error}`, EXIT.FAIL, `fix or remove ${read.path}`);
291
+ }
292
+ const held = activeLeases(read);
293
+ if (isJson(flags)) {
294
+ emitJson({ ok: true, command: 'lease', root, leases: held });
295
+ return EXIT.OK;
296
+ }
297
+ const lines = [
298
+ `repo ${root}`,
299
+ ...held.map((l) => `LEASED ${l.path} ${l.session}${l.harness ? `/${l.harness}` : ''} until ${ISO(l.expiresAt)}`),
300
+ ];
301
+ if (held.length === 0) lines.push(`(no active leases)`);
302
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
303
+ return EXIT.OK;
304
+ }
@@ -4,6 +4,8 @@
4
4
  import { resolve } from 'node:path';
5
5
 
6
6
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
+ import { diffField, emitDiffs } from '../diff.mjs';
8
+ import { commitJournal } from '../journal.mjs';
7
9
  import { CLI } from '../name.mjs';
8
10
  import { parsePathList } from '../argv.mjs';
9
11
  import {
@@ -21,13 +23,15 @@ export async function lessonsListCommand(args, flags) {
21
23
  return fail(`unknown lessons argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons`);
22
24
  }
23
25
  const root = resolve(flags.root ?? process.cwd());
24
- await captureFromLedger(root);
25
- await captureRevertedCommits(root);
26
-
26
+ // Pure: listing must not capture. Capture used to run here, so `list` wrote
27
+ // to the store it was reading and a read-only question had write side
28
+ // effects. Capturing is `lessons capture` now (review 0003 Q36).
27
29
  const [{ candidates, errors }, promoted] = await Promise.all([
28
30
  readLessonCandidates(root),
29
- listPromotedLessons(root),
31
+ listPromotedLessons(root, { includeExpired: true }),
30
32
  ]);
33
+ const live = promoted.filter((l) => !l.expired);
34
+ const expiredCount = promoted.length - live.length;
31
35
 
32
36
  const payload = {
33
37
  ok: errors.length === 0,
@@ -36,9 +40,10 @@ export async function lessonsListCommand(args, flags) {
36
40
  ledgerFile: LESSONS_FILE,
37
41
  lessonsDir: LESSONS_DIR,
38
42
  candidateCount: candidates.length,
39
- promotedCount: promoted.length,
43
+ promotedCount: live.length,
44
+ expiredCount,
40
45
  candidates,
41
- promoted,
46
+ promoted: live,
42
47
  errors,
43
48
  };
44
49
 
@@ -49,7 +54,7 @@ export async function lessonsListCommand(args, flags) {
49
54
 
50
55
  const lines = [
51
56
  `candidates ${candidates.length} (${LESSONS_FILE})`,
52
- `promoted ${promoted.length} (${LESSONS_DIR})`,
57
+ `promoted ${live.length} (${LESSONS_DIR})${expiredCount > 0 ? `, ${expiredCount} expired` : ''}`,
53
58
  ];
54
59
 
55
60
  if (candidates.length > 0) {
@@ -61,12 +66,18 @@ export async function lessonsListCommand(args, flags) {
61
66
  }
62
67
  }
63
68
 
64
- if (promoted.length > 0) {
69
+ if (live.length > 0) {
65
70
  lines.push('', 'LESSONS');
66
- for (const l of promoted) {
71
+ for (const l of live) {
67
72
  lines.push(` ${l.id.padEnd(28)} ${(l.signature ?? 'no-signature').slice(0, 19)} ${l.title}`);
68
73
  }
69
74
  }
75
+ if (expiredCount > 0) {
76
+ lines.push('', `EXPIRED ${expiredCount}`);
77
+ for (const l of promoted.filter((l) => l.expired)) {
78
+ lines.push(` ${l.id.padEnd(28)} expired ${l.expires ?? 'no date'} ${l.title}`);
79
+ }
80
+ }
70
81
 
71
82
  for (const err of errors) lines.push(`error ${err}`);
72
83
 
@@ -74,6 +85,45 @@ export async function lessonsListCommand(args, flags) {
74
85
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
75
86
  }
76
87
 
88
+ /**
89
+ * `simt lessons capture` — the explicit write half of the old `list`.
90
+ *
91
+ * Scans the ledger for fail-then-fix sequences and git history for reverts,
92
+ * and appends any new candidates. A command that writes says so in its name;
93
+ * `list` and `review` previews do not (review 0003 Q36).
94
+ */
95
+ export async function lessonsCaptureCommand(args, flags) {
96
+ if (args.length > 0) {
97
+ return fail(`unknown lessons capture argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons capture`);
98
+ }
99
+ const root = resolve(flags.root ?? process.cwd());
100
+ const fromLedger = await captureFromLedger(root);
101
+ const reverts = await captureRevertedCommits(root);
102
+
103
+ const payload = {
104
+ ok: true,
105
+ command: 'lessons capture',
106
+ root,
107
+ failThenFix: fromLedger.length,
108
+ reverts: reverts.length,
109
+ captured: [...fromLedger, ...reverts],
110
+ };
111
+ if (isJson(flags)) {
112
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
113
+ return EXIT.OK;
114
+ }
115
+ const lines = [
116
+ `captured ${payload.captured.length} candidates (${LESSONS_FILE})`,
117
+ ` fail-then-fix ${fromLedger.length}`,
118
+ ` reverts ${reverts.length}`,
119
+ ];
120
+ for (const c of payload.captured) {
121
+ lines.push(` ${c.id} ${c.kind} ${c.summary}`);
122
+ }
123
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
124
+ return EXIT.OK;
125
+ }
126
+
77
127
  export async function lessonsReviewCommand(args, flags) {
78
128
  if (args.length > 1) {
79
129
  return fail(`unknown lessons review argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} lessons review [<id>] [--yes]`);
@@ -83,18 +133,33 @@ export async function lessonsReviewCommand(args, flags) {
83
133
  const parsedPaths = parsePathList(flags.paths);
84
134
  const paths = parsedPaths.length > 0 ? parsedPaths : undefined;
85
135
  const title = flags.task ? String(flags.task) : undefined;
136
+ const expires = typeof flags.expires === 'string' && flags.expires.trim() !== '' ? flags.expires.trim() : undefined;
86
137
 
138
+ // Journaled so `undo` restores the promoted file (R49); committed only when
139
+ // the run actually wrote, which commitJournal already guarantees.
140
+ const journal = [];
87
141
  const result = await promoteLesson(root, {
88
142
  id: args[0],
89
143
  apply: mode.apply,
90
144
  paths,
91
145
  title,
146
+ expires,
147
+ journal: mode.apply ? journal : undefined,
92
148
  });
149
+ if (mode.apply && result.ok) await commitJournal(root, 'lessons review', journal);
93
150
 
94
151
  if (!result.ok) {
95
152
  return fail(result.error, EXIT.FAIL, `record a session failure-then-fix or run: ${CLI} lessons`);
96
153
  }
97
154
 
155
+ // The `--diff` pair: the promoted lesson file, created or refreshed (R50). The
156
+ // before-side rides on the result from `promoteLesson`, read before anything
157
+ // was written, so the diff is true in preview and in apply alike.
158
+ const diffs =
159
+ flags.diff === true
160
+ ? [{ path: result.path, before: result.before ?? null, after: result.markdown }]
161
+ : [];
162
+
98
163
  const payload = {
99
164
  ok: true,
100
165
  command: 'lessons review',
@@ -104,6 +169,7 @@ export async function lessonsReviewCommand(args, flags) {
104
169
  path: result.path,
105
170
  candidate: result.candidate,
106
171
  markdown: result.markdown,
172
+ ...diffField(diffs, flags),
107
173
  };
108
174
 
109
175
  if (isJson(flags)) {
@@ -120,6 +186,8 @@ export async function lessonsReviewCommand(args, flags) {
120
186
  ];
121
187
 
122
188
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
189
+ // After the report: the report says what, the diff shows it (R50).
190
+ emitDiffs(diffs, flags);
123
191
  if (!mode.apply) {
124
192
  note(`note dry-run nothing written; apply with: ${CLI} lessons review --yes`);
125
193
  }
@@ -1,11 +1,13 @@
1
1
  // `simt map` ranks the files a task is most likely to touch (R22, ADR-091).
2
- // The cache is read, and rebuilt once when it is empty, because a map of no
3
- // files is not an answer. The rebuild writes the gitignored index, which is
4
- // the same cache `simt index` writes.
2
+ // The cache is refreshed from the git index on every read: `reindex` skips unchanged
3
+ // blobs, so a current repository pays for one `git ls-files -s`, not a walk of the
4
+ // tree. A map of stale files is a wrong answer, and the commands that read with
5
+ // `build` on are ones a person typed at a terminal, where a few hundred
6
+ // milliseconds is not a bar to meet.
5
7
 
6
8
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
7
9
  import { CLI } from '../name.mjs';
8
- import { readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
10
+ import { isIndexStale, readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
9
11
  import {
10
12
  DEFAULT_BUDGET,
11
13
  embedTaskQuery,
@@ -89,20 +91,22 @@ async function recall(root, flags) {
89
91
  }
90
92
 
91
93
  /**
92
- * The files, symbols, edges, FTS rows and stored vector embeddings the index holds,
93
- * building the index once when it is empty.
94
+ * The files, symbols, edges, FTS rows and stored vector embeddings the index holds.
94
95
  *
95
- * `build: false` refuses that rebuild and answers with `cold: true` instead. The one
96
- * caller that passes it is the prompt-submit hook (R29), and the reason is the bar that
97
- * hook has to meet: a delivered packet that built an index would make a person's first
98
- * prompt in a fresh clone wait on a walk of the whole repository, and a prompt that takes
99
- * a minute to appear is how a hook gets uninstalled. The next prompt gets the packet, and
100
- * a command a person typed at a terminal always builds what it needs.
96
+ * With `build` (the default) the index is refreshed from the git index before it is
97
+ * read. The refresh is incremental — a blob that has not changed is not re-read — so
98
+ * the cost of a current repository is one `git ls-files -s`, not a walk of the tree.
99
+ * The callers that build are commands a person typed: `map`, `brief`, `slice`.
100
+ *
101
+ * `build: false` refuses the refresh and answers with `cold: true` when the index is
102
+ * empty. The callers that pass it are the prompt-submit hook (R29) and the MCP
103
+ * surface: a delivered packet that walked the whole repository would make a person's
104
+ * first prompt in a fresh clone wait, and a prompt that takes a minute to appear is
105
+ * how a hook gets uninstalled. The next prompt gets the packet, and a command a
106
+ * person typed at a terminal always builds what it needs.
101
107
  */
102
108
  export async function loadGraph(root, { build = true } = {}) {
103
- let files = await readFiles(root);
104
- if (files.length === 0) {
105
- if (!build) return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true };
109
+ if (build) {
106
110
  try {
107
111
  await reindex(root);
108
112
  } catch (error) {
@@ -114,13 +118,24 @@ export async function loadGraph(root, { build = true } = {}) {
114
118
  ),
115
119
  };
116
120
  }
117
- files = await readFiles(root);
118
121
  }
119
- const [symbols, edges, fts, embeddings] = await Promise.all([
122
+ const [files, symbols, edges, fts, embeddings] = await Promise.all([
123
+ readFiles(root),
120
124
  readSymbols(root),
121
125
  readEdges(root),
122
126
  readFts(root),
123
127
  readEmbeddings(root),
124
128
  ]);
125
- return { files, symbols, edges, fts, embeddings };
129
+ // A present index that cannot be read is named, not served as empty
130
+ // (review 0003 Q44): `cold` is "nothing here", `broken` is "something is
131
+ // here and the read failed".
132
+ const broken = !build && Boolean(files.error);
133
+ if (!build && files.length === 0) {
134
+ return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true, broken, stale: false };
135
+ }
136
+ // A caller that may not build still learns the index is old (review 0003
137
+ // Q42): the digest compare costs one `git ls-files -s`, and the freshness
138
+ // signal is what lets a UI distrust the packet instead of quoting it.
139
+ const stale = !build && files.length > 0 && (await isIndexStale(root));
140
+ return { files, symbols, edges, fts, embeddings, stale, broken };
126
141
  }
@@ -45,10 +45,10 @@ export async function mcpCommand(positional, flags) {
45
45
  [
46
46
  `simt-dev ${version} - MCP surface over stdio (protocols ${SUPPORTED_VERSIONS.join(', ')})`,
47
47
  '',
48
- `${TOOLS.length} read-only tools:`,
48
+ `${TOOLS.length} tools (all read-only except federation_query, which starts a declared server):`,
49
49
  ...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
50
50
  '',
51
- 'No tool here writes. init, sync, hooks install, skills install and upgrade stay in the',
51
+ 'Nothing here writes to your repositories. init, sync, hooks install, skills install and upgrade stay in the',
52
52
  'CLI, because each is meant to be read as a diff by a human before it touches anything.',
53
53
  '',
54
54
  `serve them with: ${positional[0] ?? `${CLI} mcp`}`,
@@ -27,6 +27,7 @@ import {
27
27
  writeScaffold,
28
28
  } from '../packs/manage.mjs';
29
29
  import { readManifest } from '../projection/manifest.mjs';
30
+ import { diffField, emitDiffs } from '../diff.mjs';
30
31
 
31
32
  export async function packCommand(sub, args, flags) {
32
33
  if (sub === 'new') return packNew(args, flags);
@@ -100,6 +101,9 @@ async function packNew(args, flags) {
100
101
  const dir = join(resolve(flags.root ?? process.cwd()), name);
101
102
  const files = scaffoldPack({ name, source });
102
103
  const { apply, dryRun } = writeMode(flags);
104
+ // A scaffold always creates into a directory that must not exist, so every
105
+ // before side is empty and each pair is a whole file (R50).
106
+ const diffs = flags.diff === true ? files.map((f) => ({ path: `${name}/${f.path}`, before: null, after: f.text })) : [];
103
107
 
104
108
  if (isJson(flags)) {
105
109
  emitJson({
@@ -110,6 +114,7 @@ async function packNew(args, flags) {
110
114
  files: files.map((f) => f.path),
111
115
  packFile: PACK_JSON,
112
116
  benchFile: BENCH_JSON,
117
+ ...diffField(diffs, flags),
113
118
  });
114
119
  return EXIT.OK;
115
120
  }
@@ -126,6 +131,8 @@ async function packNew(args, flags) {
126
131
  ].join('\n'),
127
132
  { maxBytes: flags.maxBytes ?? 0 },
128
133
  );
134
+ // After the report: the report says what, the diff shows it (R50).
135
+ emitDiffs(diffs, flags);
129
136
  return EXIT.OK;
130
137
  }
131
138
 
@@ -144,6 +151,8 @@ async function packNew(args, flags) {
144
151
  ].join('\n'),
145
152
  { maxBytes: flags.maxBytes ?? 0 },
146
153
  );
154
+ // After the report: the report says what, the diff shows it (R50).
155
+ emitDiffs(diffs, flags);
147
156
  return EXIT.OK;
148
157
  }
149
158
 
@@ -1,5 +1,7 @@
1
+ import { relative } from 'node:path';
1
2
  import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
2
3
  import { formatCounts, planDone, planNext, planStatus } from '../plan/index.mjs';
4
+ import { diffField, emitDiffs } from '../diff.mjs';
3
5
  import { CLI } from '../name.mjs';
4
6
 
5
7
  // Preview unless --yes, and --dry-run wins even then. A tracker row marked done
@@ -79,8 +81,16 @@ async function runDone(root, id, sha, flags) {
79
81
  dryRun: !mode.apply,
80
82
  });
81
83
  if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
84
+ // The `--diff` pair: the one changed tracker row (R50). The sides ride on the
85
+ // result from `planDone`, captured pre-write, so the diff is true in preview
86
+ // and in apply alike. When nothing changed the sides are equal and the diff
87
+ // renders empty.
88
+ const diffs =
89
+ flags.diff === true && result.beforeLine !== undefined
90
+ ? [{ path: relative(root, result.file), before: result.beforeLine, after: result.afterLine }]
91
+ : [];
82
92
  if (isJson(flags)) {
83
- emitJson({ ok: true, command: 'plan done', ...result });
93
+ emitJson({ ok: true, command: 'plan done', ...result, ...diffField(diffs, flags) });
84
94
  return EXIT.OK;
85
95
  }
86
96
  const lines = [`repo ${root}`];
@@ -95,5 +105,7 @@ async function runDone(root, id, sha, flags) {
95
105
  if (result.evidenceRecorded) lines.push(`evidence ${result.sha}${result.test ? ` test: ${result.test}` : ''}`);
96
106
  else if (result.evidenceNote) lines.push(`note ${result.evidenceNote}`);
97
107
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
108
+ // After the report: the report says what, the diff shows it (R50).
109
+ emitDiffs(diffs, flags);
98
110
  return EXIT.OK;
99
111
  }