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
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // A row in a tracker is a promise, and these are the two moments it is acted on.
4
4
  // `start` isolates the work: a linked worktree on a branch named for the row, so the
5
- // main checkout stays free for whatever else is running, and the task brief already on
6
- // disk at `.simt/brief.md` — the same path the bench's brief arm writes — where an
7
- // agent picks it up. `finish` closes the loop the way the repository's own hook would:
5
+ // main checkout stays free for whatever else is running, and a task brief assembled in
6
+ // the main checkout at `.simt/local/briefs/<id>.md`, beside the worktree and outside
7
+ // its tree, where an agent picks it up. `finish` closes the loop the way the repository's own hook would:
8
8
  // the declared gates run through the shared dispatcher first, because a command that
9
9
  // commits for you may not assume a hook is installed to stop it, then the commit
10
10
  // carries a Conventional subject built from the row's own title, and only then does
@@ -33,15 +33,41 @@ import { promisify } from 'node:util';
33
33
 
34
34
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
35
35
  import { planDone, planNext } from '../plan/index.mjs';
36
+ import { loadFormat } from '../plan/template.mjs';
36
37
  import { assembleBrief } from '../brief/assemble.mjs';
37
38
  import { renderGateReport, runGates, stagedFiles } from '../hooks/dispatcher.mjs';
39
+ import { GATES } from '../hooks/gates.mjs';
40
+ import { indexTreeId, passEntryForGate, readLedger } from '../ledger/index.mjs';
41
+ import {
42
+ DEFAULT_TTL_SECONDS,
43
+ activeLeases,
44
+ canonicalLeasePath,
45
+ readLeases,
46
+ withLeaseLock,
47
+ writeLeases,
48
+ } from '../lease.mjs';
49
+ import { SESSION_ENV } from '../agenthooks/policy.mjs';
50
+ import { assertVersionPin } from '../package-version.mjs';
51
+ import { coreIgnoreCase } from '../repo-root.mjs';
38
52
  import { readManifest } from '../projection/manifest.mjs';
39
- import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
53
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
54
+ import { LOCAL_DIR } from '../state.mjs';
55
+ import { commandRoot } from '../repo-root.mjs';
40
56
 
41
57
  const exec = promisify(execFile);
42
58
 
43
- /** The state a started slice moves to, spelled the way the tracker template spells it. */
44
- const STARTED = 'In progress';
59
+ /**
60
+ * The state a started slice moves to: the template's `started`-role state, not
61
+ * a hard-coded word. A repository that renamed its states still starts slices,
62
+ * and one that deleted the in-progress state fails closed in `plan done` on a
63
+ * word its own template never defined (review 0003 Q23).
64
+ */
65
+ async function startedState(main) {
66
+ const loaded = await loadFormat(main);
67
+ if (loaded.error) return { error: loaded.error, remedy: loaded.remedy };
68
+ const { format } = loaded;
69
+ return { state: format.startedWrite ?? format.canonical('in-progress') ?? 'In progress' };
70
+ }
45
71
 
46
72
  /**
47
73
  * A Conventional Commits type: one lower-case word, optionally scoped, optionally
@@ -74,12 +100,16 @@ async function mainCheckout(root) {
74
100
  }
75
101
  }
76
102
 
77
- /** The branch and worktree a slice owns. Both derive from the id: one id, one spelling. */
103
+ /**
104
+ * The branch and worktree a slice owns. Both derive from the id: one id, one spelling.
105
+ * The worktree lives under machine-local state (review 0003 Q19): a worktree inside
106
+ * the checkout would be recorded as an embedded gitlink and would change the tree id.
107
+ */
78
108
  function places(main, id) {
79
109
  const slug = id.toLowerCase();
80
110
  return {
81
111
  branch: `slice/${slug}`,
82
- worktree: join(main, STATE_DIR, 'worktrees', slug),
112
+ worktree: join(main, LOCAL_DIR, 'worktrees', slug),
83
113
  };
84
114
  }
85
115
 
@@ -96,6 +126,15 @@ function subjectFor(type, id, title) {
96
126
  return `${type}(${id.toLowerCase()}): ${description}`;
97
127
  }
98
128
 
129
+ /**
130
+ * The trailer of every commit this tool writes: attribution to the tool, in the
131
+ * form git renders as a co-author. The slice-commit rule ends every message it
132
+ * defines with the same line, so a commit the tool writes and one an agent
133
+ * writes from the rule agree on attribution. Module-local: one caller needs no
134
+ * export, and an export is a promise to a caller (deadcode).
135
+ */
136
+ const CO_AUTHOR = 'Co-Authored-By: Simt Dev <noreply@simt.space>';
137
+
99
138
  /** Resolve one named slice through `plan next`, which already knows missing and ambiguous. */
100
139
  async function resolveSlice(main, id) {
101
140
  const found = await planNext(main, id);
@@ -104,8 +143,83 @@ async function resolveSlice(main, id) {
104
143
  return { slice: found.next };
105
144
  }
106
145
 
146
+ /** The session holding the slice, or null when the caller named none: a person
147
+ * at a terminal has no session, and starting without one proceeds unleased
148
+ * rather than refusing the work. */
149
+ function sliceSession(flags) {
150
+ const session = flags.session ?? process.env[SESSION_ENV] ?? null;
151
+ return typeof session === 'string' && session !== '' ? session : null;
152
+ }
153
+
154
+ /**
155
+ * The slice's lease path, in the spelling the pre-edit hook compares: the
156
+ * worktree directory, repository-relative. Entries carry `kind: 'slice'` so a
157
+ * later schema (review 0003 Q74) can tell slice claims from path claims; the
158
+ * v1 reader keeps the extra fields without understanding them.
159
+ */
160
+ function sliceLeaseRel(main, worktree) {
161
+ return canonicalLeasePath(main, worktree, { ignoreCase: coreIgnoreCase(main) });
162
+ }
163
+
164
+ /**
165
+ * Claim the slice for one session: a second session starting the same slice
166
+ * is refused with the holder's name instead of racing it to the worktree.
167
+ * A claim held by this session is refreshed; anything else on the file is
168
+ * left alone.
169
+ */
170
+ async function claimSlice(main, id, leaseRel, session) {
171
+ try {
172
+ return await withLeaseLock(main, async () => {
173
+ const read = await readLeases(main);
174
+ if (read.error !== null) return { error: read.error, path: read.path };
175
+ const held = activeLeases(read);
176
+ const other = held.find(
177
+ (l) => l.session !== session && ((l.kind === 'slice' && l.slice === id) || (leaseRel !== null && l.path === leaseRel)),
178
+ );
179
+ if (other) return { conflict: other };
180
+ const mine = {
181
+ path: leaseRel,
182
+ session,
183
+ kind: 'slice',
184
+ slice: id,
185
+ expiresAt: Date.now() + DEFAULT_TTL_SECONDS * 1000,
186
+ };
187
+ const kept = held.filter((l) => !(l.kind === 'slice' && l.slice === id && l.session === session));
188
+ const path = await writeLeases(main, [...kept, mine]);
189
+ return { mine, path };
190
+ });
191
+ } catch (error) {
192
+ return { error: error.message };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Release this session's claim on the slice. Best-effort by design: `finish`
198
+ * calls it after the commit exists, so a lease failure is a note about a TTL
199
+ * that will clean itself up, never a second failure stacked on a success.
200
+ */
201
+ async function releaseSlice(main, id, session) {
202
+ if (session === null) return { released: false };
203
+ try {
204
+ return await withLeaseLock(main, async () => {
205
+ const read = await readLeases(main);
206
+ if (read.error !== null) return { error: read.error };
207
+ const held = activeLeases(read);
208
+ const mine = held.filter((l) => l.kind === 'slice' && l.slice === id && l.session === session);
209
+ if (mine.length === 0) return { released: false };
210
+ await writeLeases(
211
+ main,
212
+ held.filter((l) => !mine.includes(l)),
213
+ );
214
+ return { released: true };
215
+ });
216
+ } catch (error) {
217
+ return { error: error.message };
218
+ }
219
+ }
220
+
107
221
  export async function sliceCommand(sub, args, flags) {
108
- const root = flags.root ?? process.cwd();
222
+ const root = commandRoot(flags);
109
223
  if (sub === 'start') {
110
224
  const id = args[0];
111
225
  if (!id) return fail(`${CLI} slice start needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
@@ -116,7 +230,16 @@ export async function sliceCommand(sub, args, flags) {
116
230
  if (!id) return fail(`${CLI} slice finish needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
117
231
  return finishSlice(root, id, flags);
118
232
  }
119
- return fail(`unknown slice subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} slice start <id> | ${CLI} slice finish <id>`);
233
+ if (sub === 'abandon') {
234
+ const id = args[0];
235
+ if (!id) return fail(`${CLI} slice abandon needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
236
+ return abandonSlice(root, id, flags);
237
+ }
238
+ return fail(
239
+ `unknown slice subcommand ${sub}`,
240
+ EXIT.USAGE,
241
+ `run: ${CLI} slice start <id> | ${CLI} slice finish <id> | ${CLI} slice abandon <id>`,
242
+ );
120
243
  }
121
244
 
122
245
  async function startSlice(root, id, flags) {
@@ -126,16 +249,25 @@ async function startSlice(root, id, flags) {
126
249
  if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
127
250
  const { title, doc } = resolved.slice;
128
251
  const { branch, worktree } = places(main, id);
129
- const briefPath = join(worktree, STATE_DIR, 'brief.md');
252
+ // The brief lives beside the worktree, not in it: a file inside the worktree
253
+ // is untracked content in the slice's own tree, and the tree id must name
254
+ // the code, not the briefing (review 0003 Q19, Q23).
255
+ const briefPath = join(main, LOCAL_DIR, 'briefs', `${id.toLowerCase()}.md`);
256
+ const leaseRel = sliceLeaseRel(main, worktree);
257
+ const session = sliceSession(flags);
130
258
  const task = `${id}: ${String(title ?? '').trim()}`;
131
259
  const apply = `run: ${CLI} slice start ${id} --yes`;
132
260
 
261
+ const started = await startedState(main);
262
+ if (started.error) return fail(started.error, EXIT.USAGE, started.remedy);
133
263
  // The row first, dry, against the main checkout: it is the only part of `start`
134
264
  // that can refuse for a reason about the tracker rather than about git, and a
135
265
  // preview that discovers it late is a preview that half-described the work.
136
- const preview = await planDone(main, id, { state: STARTED, dryRun: true });
266
+ const preview = await planDone(main, id, { state: started.state, dryRun: true });
137
267
  if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
138
268
 
269
+ const leaseLine = session === null ? 'lease (no session: pass --session or set SIMT_SESSION to lease the slice)' : `lease slice ${id} as ${session} (claimed on apply)`;
270
+
139
271
  if (!mode.apply) {
140
272
  if (isJson(flags)) {
141
273
  emitJson({
@@ -148,6 +280,7 @@ async function startSlice(root, id, flags) {
148
280
  branch,
149
281
  worktree: relative(main, worktree),
150
282
  brief: { path: relative(main, briefPath), bytes: null },
283
+ lease: session === null ? null : { slice: id, path: leaseRel, session },
151
284
  state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
152
285
  remedy: apply,
153
286
  });
@@ -159,10 +292,11 @@ async function startSlice(root, id, flags) {
159
292
  `branch ${branch}`,
160
293
  `worktree ${relative(main, worktree)}`,
161
294
  `brief ${relative(main, briefPath)} (assembled on apply)`,
295
+ leaseLine,
162
296
  `state ${preview.from} -> ${preview.to}`,
163
297
  `apply ${apply}`,
164
298
  ];
165
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
299
+ emit(lines.join('\n'));
166
300
  return EXIT.OK;
167
301
  }
168
302
 
@@ -178,6 +312,26 @@ async function startSlice(root, id, flags) {
178
312
  return fail(`the index at ${main} is empty and will not build here`, EXIT.UNSUPPORTED, `run: ${CLI} index`);
179
313
  }
180
314
 
315
+ // Claimed before the worktree exists: a second session starting the same slice
316
+ // is refused here, not after a worktree that then has to be taken back down.
317
+ let lease = null;
318
+ if (session !== null) {
319
+ const claimed = await claimSlice(main, id, leaseRel, session);
320
+ if (claimed.error) {
321
+ return fail(`the slice could not be leased: ${claimed.error}`, EXIT.FAIL, `run: ${CLI} lease to see every claim`);
322
+ }
323
+ if (claimed.conflict) {
324
+ const holder = claimed.conflict.session;
325
+ const until = new Date(claimed.conflict.expiresAt).toISOString();
326
+ return fail(
327
+ `${id} is already started by session ${holder} (lease until ${until})`,
328
+ EXIT.FAIL,
329
+ `wait for the TTL or ask ${holder} to finish or abandon it; run: ${CLI} lease to see every claim`,
330
+ );
331
+ }
332
+ lease = claimed.mine;
333
+ }
334
+
181
335
  try {
182
336
  await git(main, ['worktree', 'add', '-b', branch, worktree]);
183
337
  } catch (error) {
@@ -185,15 +339,16 @@ async function startSlice(root, id, flags) {
185
339
  return fail(`the worktree could not be created: ${detail}`, EXIT.FAIL, `run: git -C ${main} worktree list`);
186
340
  }
187
341
 
188
- await mkdir(join(worktree, STATE_DIR), { recursive: true });
342
+ await mkdir(dirname(briefPath), { recursive: true });
189
343
  await writeFile(briefPath, packet.packed.text, 'utf8');
190
344
 
191
- const row = await planDone(main, id, { state: STARTED, dryRun: false });
345
+ const row = await planDone(main, id, { state: started.state, dryRun: false });
192
346
  if (row.error) {
193
347
  return fail(`${branch} exists with its brief, but the tracker row was not updated: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
194
348
  }
195
349
 
196
350
  const bytes = Buffer.byteLength(packet.packed.text);
351
+ if (session === null) note('note no session: the slice is not leased; a second start will not see this one');
197
352
  if (isJson(flags)) {
198
353
  emitJson({
199
354
  ok: true,
@@ -204,6 +359,7 @@ async function startSlice(root, id, flags) {
204
359
  branch,
205
360
  worktree: relative(main, worktree),
206
361
  brief: { path: relative(main, briefPath), bytes },
362
+ lease,
207
363
  state: { from: row.from, to: row.to, changed: row.changed },
208
364
  });
209
365
  return EXIT.OK;
@@ -214,18 +370,62 @@ async function startSlice(root, id, flags) {
214
370
  `branch ${branch}`,
215
371
  `worktree ${relative(main, worktree)}`,
216
372
  `brief ${relative(main, briefPath)} ${bytes} bytes`,
373
+ lease === null ? 'lease (none)' : `lease slice ${id} as ${session}`,
217
374
  `state ${row.from} -> ${row.to}`,
218
375
  ];
219
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
376
+ emit(lines.join('\n'));
220
377
  return EXIT.OK;
221
378
  }
222
379
 
380
+ /**
381
+ * Split the declared gates into the ones that must run and the ones the ledger
382
+ * already proves: a gate with a passing entry on exactly this staged tree, and
383
+ * no later failure for the same gate on the same tree, does not run again.
384
+ * That is the double gate the review verified — the dispatcher, then the
385
+ * commit hook — and the seed of the gate-run cache (review 0003 Q23).
386
+ *
387
+ * Fail-closed throughout: no tree, no ledger, a version mismatch, or an
388
+ * unknown gate all mean the gate runs. An entry recorded under another binary
389
+ * is not evidence for this one, so a pin mismatch disables the cache and
390
+ * routes through `runGates`, which fails on the mismatch itself.
391
+ */
392
+ async function splitGates(root, declared, manifestDoc) {
393
+ if (!Array.isArray(declared) || declared.length === 0) return { fresh: [], skipped: [], tree: null };
394
+ if (assertVersionPin(manifestDoc)) return { fresh: [...declared], skipped: [], tree: null };
395
+ const stagedTree = await indexTreeId(root).catch(() => null);
396
+ if (stagedTree === null) return { fresh: [...declared], skipped: [], tree: null };
397
+ const { entries } = await readLedger(root).catch(() => ({ entries: [] }));
398
+ const fresh = [];
399
+ const skipped = [];
400
+ for (const name of declared) {
401
+ if (GATES[name] === undefined) {
402
+ fresh.push(name);
403
+ continue;
404
+ }
405
+ const pass = passEntryForGate(entries, name, stagedTree);
406
+ if (pass === null) fresh.push(name);
407
+ else skipped.push({ name, at: pass.ts });
408
+ }
409
+ return { fresh, skipped, tree: stagedTree };
410
+ }
411
+
412
+ /** A gate result that did not run: the ledger line is the evidence. */
413
+ function cachedResult({ name, at }) {
414
+ return {
415
+ name,
416
+ status: 'passed',
417
+ detail: `already passed on this tree at ${at} (ledger); not re-run`,
418
+ cached: true,
419
+ };
420
+ }
421
+
223
422
  async function finishSlice(root, id, flags) {
224
423
  const mode = writeMode(flags);
225
424
  const main = await mainCheckout(root);
226
425
  const resolved = await resolveSlice(main, id);
227
426
  if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
228
427
  const { title, doc } = resolved.slice;
428
+ const { branch, worktree } = places(main, id);
229
429
 
230
430
  const type = String(flags.type ?? 'feat');
231
431
  if (!TYPE.test(type)) {
@@ -245,10 +445,15 @@ async function finishSlice(root, id, flags) {
245
445
  const apply = `run: ${CLI} slice finish ${id}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
246
446
 
247
447
  const staged = await stagedFiles(root);
448
+ const split = await splitGates(root, declared, manifest.manifest);
248
449
 
249
450
  if (!mode.apply) {
250
451
  const row = await planDone(main, id, { sha: null, test, dryRun: true });
251
452
  if (row.error) return fail(row.error, EXIT.USAGE, row.remedy ?? `run: ${CLI} plan status`);
453
+ const gateLine =
454
+ declared.length === 0
455
+ ? '(none declared)'
456
+ : `${declared.join(', ')}${split.skipped.length > 0 ? ` (already passed on this tree: ${split.skipped.map((s) => s.name).join(', ')})` : ''}`;
252
457
  if (isJson(flags)) {
253
458
  emitJson({
254
459
  ok: true,
@@ -258,7 +463,7 @@ async function finishSlice(root, id, flags) {
258
463
  dryRun: true,
259
464
  subject,
260
465
  staged,
261
- gates: { event: 'pre-commit', declared },
466
+ gates: { event: 'pre-commit', declared, skipped: split.skipped.map((s) => s.name) },
262
467
  state: { from: row.from, to: row.to, changed: row.wouldChange },
263
468
  remedy: apply,
264
469
  });
@@ -267,25 +472,72 @@ async function finishSlice(root, id, flags) {
267
472
  const lines = [
268
473
  `repo ${root}`,
269
474
  `slice ${id} ${doc}`,
270
- `gates pre-commit ${declared.length > 0 ? declared.join(', ') : '(none declared)'} (run on apply)`,
475
+ `gates pre-commit ${gateLine} (run on apply)`,
271
476
  `staged ${staged.length} file(s)${staged.length === 0 ? ' (finish refuses without one)' : ''}`,
272
477
  `subject ${subject}`,
273
478
  `state ${row.from} -> ${row.to}`,
274
479
  `apply ${apply}`,
275
480
  ];
276
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
481
+ emit(lines.join('\n'));
277
482
  return EXIT.OK;
278
483
  }
279
484
 
485
+ // The commit lands on the slice branch or not at all: finishing from `main`
486
+ // commits main's tree under the slice's subject, which is the defect this
487
+ // guard closes (review 0003 Q23).
488
+ const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => null);
489
+ if (head !== branch) {
490
+ return fail(
491
+ `not on the slice branch: HEAD is ${head ?? 'unknown'}, expected ${branch}`,
492
+ EXIT.USAGE,
493
+ `move to the slice worktree (${relative(main, worktree)}) and rerun: ${apply}`,
494
+ );
495
+ }
496
+
280
497
  if (staged.length === 0) {
281
498
  return fail(`${root} has nothing staged to commit`, EXIT.USAGE, `stage what the slice changed, then rerun: ${apply}`);
282
499
  }
283
500
 
284
501
  // The gates the hook would run, run here. This command is the one place a commit
285
502
  // happens where a hook could not have stopped it, so it runs the same dispatcher
286
- // over the same event, in the checkout that holds the staged diff.
287
- const gates = await runGates('pre-commit', { root, manifest: manifest.manifest });
288
- if (gates.error) return fail(gates.error, EXIT.USAGE);
503
+ // over the same event, in the checkout that holds the staged diff — except the
504
+ // gates the ledger already proves for this tree, which are reported, not re-run.
505
+ const manifestForGates =
506
+ split.fresh.length === declared.length
507
+ ? manifest.manifest
508
+ : { ...manifest.manifest, gates: { ...(manifest.manifest?.gates ?? {}), preCommit: split.fresh } };
509
+ const freshGates =
510
+ split.fresh.length === 0 && declared.length > 0
511
+ ? { event: 'pre-commit', gates: [], failed: false, notes: [] }
512
+ : await runGates('pre-commit', { root, manifest: manifestForGates });
513
+ if (freshGates.error) return fail(freshGates.error, EXIT.USAGE);
514
+
515
+ // A gate that rewrites the tree (the formatter restaging) moves the commit
516
+ // off the tree the skipped gates passed on. Their evidence is stale, so they
517
+ // run once on the new tree; those results stand, the way fresh results do.
518
+ let skipped = split.skipped;
519
+ if (skipped.length > 0 && split.tree !== null) {
520
+ const afterTree = await indexTreeId(root).catch(() => null);
521
+ if (afterTree !== null && afterTree !== split.tree) {
522
+ const { entries } = await readLedger(root).catch(() => ({ entries: [] }));
523
+ const stale = skipped.filter((s) => passEntryForGate(entries, s.name, afterTree) === null);
524
+ if (stale.length > 0) {
525
+ const rerun = await runGates('pre-commit', {
526
+ root,
527
+ manifest: { ...manifest.manifest, gates: { ...(manifest.manifest?.gates ?? {}), preCommit: stale.map((s) => s.name) } },
528
+ });
529
+ if (rerun.error) return fail(rerun.error, EXIT.USAGE);
530
+ freshGates.gates.push(...rerun.gates);
531
+ if (rerun.failed) freshGates.failed = true;
532
+ for (const n of rerun.notes ?? []) freshGates.notes.push(n);
533
+ skipped = skipped.filter((s) => !stale.some((t) => t.name === s.name));
534
+ }
535
+ }
536
+ }
537
+
538
+ const byName = new Map(freshGates.gates.map((g) => [g.name, g]));
539
+ const combined = declared.map((name) => byName.get(name) ?? cachedResult(skipped.find((s) => s.name === name)));
540
+ const gates = { ...freshGates, gates: combined };
289
541
 
290
542
  if (gates.failed) {
291
543
  const failed = gates.gates.filter((g) => g.status === 'failed').map((g) => g.name).join(', ');
@@ -303,17 +555,18 @@ async function finishSlice(root, id, flags) {
303
555
  return EXIT.FAIL;
304
556
  }
305
557
  for (const n of gates.notes ?? []) note(`note ${n}`);
306
- emit(renderGateReport('pre-commit', declared, gates).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
558
+ emit(renderGateReport('pre-commit', declared, gates).join('\n'));
307
559
  return EXIT.FAIL;
308
560
  }
309
561
 
310
562
  let sha = null;
563
+ const message = `${subject}\n\n${CO_AUTHOR}`;
311
564
  try {
312
- await git(root, ['commit', '-m', subject]);
565
+ await git(root, ['commit', '-m', message]);
313
566
  sha = await git(root, ['rev-parse', 'HEAD']);
314
567
  } catch (error) {
315
568
  const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
316
- return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(subject)}`);
569
+ return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(message)}`);
317
570
  }
318
571
 
319
572
  const row = await planDone(main, id, { sha, test, dryRun: false });
@@ -321,6 +574,12 @@ async function finishSlice(root, id, flags) {
321
574
  return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
322
575
  }
323
576
 
577
+ // The slice is done: its lease goes with it. A failure here is a note — the
578
+ // commit exists, and a lease the TTL will reap is not a second failure.
579
+ const released = await releaseSlice(main, id, sliceSession(flags));
580
+ if (released.error) note(`note the slice lease could not be released (${released.error}); the TTL reaps it`);
581
+
582
+ for (const s of skipped) note(`note ${s.name}: already passed on this tree; not re-run`);
324
583
  if (isJson(flags)) {
325
584
  emitJson({
326
585
  ok: true,
@@ -348,6 +607,117 @@ async function finishSlice(root, id, flags) {
348
607
  ];
349
608
  if (row.evidenceRecorded) lines.push(`evidence ${sha}${test ? ` test: ${test}` : ''}`);
350
609
  else if (row.evidenceNote) lines.push(`note ${row.evidenceNote}`);
351
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
610
+ emit(lines.join('\n'));
611
+ return EXIT.OK;
612
+ }
613
+
614
+ /**
615
+ * `simt slice abandon <id>`: take the slice back down. The worktree is
616
+ * removed, the branch deleted, and the tracker row returns to the ready
617
+ * state — abandonment is a state the tracker can see, not a worktree that
618
+ * silently rots. Preview names everything that would be destroyed; `--yes`
619
+ * is the consent to destroy it (review 0003 Q23).
620
+ */
621
+ async function abandonSlice(root, id, flags) {
622
+ const mode = writeMode(flags);
623
+ const main = await mainCheckout(root);
624
+ const resolved = await resolveSlice(main, id);
625
+ if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
626
+ const { doc } = resolved.slice;
627
+ const { branch, worktree } = places(main, id);
628
+ const session = sliceSession(flags);
629
+ const apply = `run: ${CLI} slice abandon ${id} --yes`;
630
+
631
+ const loaded = await loadFormat(main);
632
+ if (loaded.error) return fail(loaded.error, EXIT.USAGE, loaded.remedy);
633
+ const readyDisplay = loaded.format.canonical(loaded.format.ready) ?? 'Queued';
634
+ const preview = await planDone(main, id, { state: readyDisplay, dryRun: true });
635
+ if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
636
+
637
+ if (!mode.apply) {
638
+ if (isJson(flags)) {
639
+ emitJson({
640
+ ok: true,
641
+ command: 'slice abandon',
642
+ root,
643
+ id,
644
+ dryRun: true,
645
+ branch,
646
+ worktree: relative(main, worktree),
647
+ state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
648
+ remedy: apply,
649
+ });
650
+ return EXIT.OK;
651
+ }
652
+ const lines = [
653
+ `repo ${root}`,
654
+ `slice ${id} ${doc}`,
655
+ `remove ${relative(main, worktree)} and branch ${branch} (on apply)`,
656
+ `state ${preview.from} -> ${preview.to}`,
657
+ `apply ${apply}`,
658
+ ];
659
+ emit(lines.join('\n'));
660
+ return EXIT.OK;
661
+ }
662
+
663
+ // Abandoning from inside the worktree would pull the floor out: the shell's
664
+ // own directory is the thing being removed. Compared through git, not through
665
+ // strings, because the caller's spelling of the path may differ in symlinks.
666
+ const top = await git(root, ['rev-parse', '--show-toplevel']).catch(() => null);
667
+ if (top !== null && (top === worktree || top.startsWith(`${worktree}/`))) {
668
+ return fail(
669
+ `cannot abandon ${id} from inside its worktree`,
670
+ EXIT.USAGE,
671
+ `move to the main checkout (${main}) and rerun: ${apply}`,
672
+ );
673
+ }
674
+
675
+ const removed = [];
676
+ try {
677
+ await git(main, ['worktree', 'remove', '--force', worktree]);
678
+ removed.push(`removed ${relative(main, worktree)}`);
679
+ } catch (error) {
680
+ const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
681
+ removed.push(`kept ${relative(main, worktree)} (${detail})`);
682
+ }
683
+ try {
684
+ await git(main, ['branch', '-D', branch]);
685
+ removed.push(`deleted ${branch}`);
686
+ } catch (error) {
687
+ const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
688
+ removed.push(`kept ${branch} (${detail})`);
689
+ }
690
+
691
+ const row = await planDone(main, id, { state: readyDisplay, dryRun: false });
692
+ if (row.error) {
693
+ return fail(
694
+ `the worktree is down, but the tracker row was not updated: ${row.error}`,
695
+ EXIT.FAIL,
696
+ row.remedy ?? `run: ${CLI} plan status`,
697
+ );
698
+ }
699
+
700
+ const released = await releaseSlice(main, id, session);
701
+ const leaseLine =
702
+ released.released === true
703
+ ? `released the slice lease`
704
+ : released.error
705
+ ? `the slice lease could not be released (${released.error}); the TTL reaps it`
706
+ : 'no slice lease was held';
707
+ if (isJson(flags)) {
708
+ emitJson({
709
+ ok: true,
710
+ command: 'slice abandon',
711
+ root,
712
+ id,
713
+ branch,
714
+ worktree: relative(main, worktree),
715
+ lease: released.released === true ? { slice: id, session } : null,
716
+ state: { from: row.from, to: row.to, changed: row.changed },
717
+ });
718
+ return EXIT.OK;
719
+ }
720
+ const lines = [`repo ${root}`, `slice ${id} ${doc}`, ...removed, `state ${row.from} -> ${row.to}`, `lease ${leaseLine}`];
721
+ emit(lines.join('\n'));
352
722
  return EXIT.OK;
353
723
  }