simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -26,10 +26,8 @@
26
26
  // slice's code: editing the same row in both places would make every merge of a
27
27
  // finished slice a conflict on the one line that records it.
28
28
 
29
- import { execFile } from 'node:child_process';
30
- import { mkdir, writeFile } from 'node:fs/promises';
29
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
31
30
  import { dirname, join, relative, resolve } from 'node:path';
32
- import { promisify } from 'node:util';
33
31
 
34
32
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
35
33
  import { planDone, planNext } from '../plan/index.mjs';
@@ -53,8 +51,7 @@ import { readManifest } from '../projection/manifest.mjs';
53
51
  import { CLI, MANIFEST_FILE } from '../name.mjs';
54
52
  import { LOCAL_DIR } from '../state.mjs';
55
53
  import { commandRoot } from '../repo-root.mjs';
56
-
57
- const exec = promisify(execFile);
54
+ import { git, gitText } from '../sys/git.mjs';
58
55
 
59
56
  /**
60
57
  * The state a started slice moves to: the template's `started`-role state, not
@@ -77,11 +74,6 @@ async function startedState(main) {
77
74
  */
78
75
  const TYPE = /^[a-z]+(?:\([a-z0-9][a-z0-9._-]*\))?!?$/;
79
76
 
80
- async function git(root, args) {
81
- const { stdout } = await exec('git', ['-C', root, ...args], { maxBuffer: 32 << 20 });
82
- return stdout.trim();
83
- }
84
-
85
77
  /**
86
78
  * The checkout that owns the tracker: the main worktree when `root` is a linked one.
87
79
  *
@@ -93,7 +85,7 @@ async function git(root, args) {
93
85
  */
94
86
  async function mainCheckout(root) {
95
87
  try {
96
- const common = await git(root, ['rev-parse', '--git-common-dir']);
88
+ const common = await gitText(root, ['rev-parse', '--git-common-dir']);
97
89
  return dirname(resolve(root, common));
98
90
  } catch {
99
91
  return root;
@@ -143,6 +135,47 @@ async function resolveSlice(main, id) {
143
135
  return { slice: found.next };
144
136
  }
145
137
 
138
+ /**
139
+ * The id for a prompt slice: a slug of the prompt text, capped so the branch and
140
+ * worktree names it feeds stay short. Whitespace and punctuation fold to one hyphen,
141
+ * and empty text falls back to `slice` rather than naming the slice after nothing.
142
+ */
143
+ function slugify(text) {
144
+ const slug = String(text ?? '')
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9]+/g, '-')
147
+ .replace(/^-+|-+$/g, '')
148
+ .slice(0, 48)
149
+ .replace(/-+$/g, '');
150
+ return slug === '' ? 'slice' : slug;
151
+ }
152
+
153
+ /**
154
+ * The record a prompt slice writes in place of a tracker row. A prompt slice is not in
155
+ * a plan, so `planDone` has no row to flip and `planNext` has no section to read; the
156
+ * record is the only place its existence is stated, and it is what `finish` and
157
+ * `abandon` fall back to when the tracker has no row for the id. It lives beside the
158
+ * briefs, under the local state directory, not in the tree the slice commits.
159
+ */
160
+ function recordPath(main, id) {
161
+ return join(main, LOCAL_DIR, 'slices', `${id.toLowerCase()}.json`);
162
+ }
163
+
164
+ async function writeSliceRecord(main, id, record) {
165
+ const path = recordPath(main, id);
166
+ await mkdir(dirname(path), { recursive: true });
167
+ await writeFile(path, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
168
+ return path;
169
+ }
170
+
171
+ async function readSliceRecord(main, id) {
172
+ try {
173
+ return JSON.parse(await readFile(recordPath(main, id), 'utf8'));
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
146
179
  /** The session holding the slice, or null when the caller named none: a person
147
180
  * at a terminal has no session, and starting without one proceeds unleased
148
181
  * rather than refusing the work. */
@@ -221,9 +254,20 @@ async function releaseSlice(main, id, session) {
221
254
  export async function sliceCommand(sub, args, flags) {
222
255
  const root = commandRoot(flags);
223
256
  if (sub === 'start') {
257
+ // Prompt mode: `simt slice start -- "<text>"`. The `--` is the marker, so the
258
+ // positional after it is the prompt, not a tracker id — there is no row to start,
259
+ // and the id is derived from the text (or pinned with --id) rather than read.
260
+ if (flags.dashdash === true) {
261
+ const prompt = args[0];
262
+ if (prompt === undefined || prompt === '') {
263
+ return fail(`${CLI} slice start -- needs a prompt after --`, EXIT.USAGE, `run: ${CLI} slice start -- "<text>"`);
264
+ }
265
+ const id = flags.id ?? slugify(prompt);
266
+ return startSlice(root, id, flags, prompt);
267
+ }
224
268
  const id = args[0];
225
269
  if (!id) return fail(`${CLI} slice start needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
226
- return startSlice(root, id, flags);
270
+ return startSlice(root, id, flags, null);
227
271
  }
228
272
  if (sub === 'finish') {
229
273
  const id = args[0];
@@ -242,12 +286,22 @@ export async function sliceCommand(sub, args, flags) {
242
286
  );
243
287
  }
244
288
 
245
- async function startSlice(root, id, flags) {
289
+ async function startSlice(root, id, flags, prompt) {
246
290
  const mode = writeMode(flags);
247
291
  const main = await mainCheckout(root);
248
- const resolved = await resolveSlice(main, id);
249
- if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
250
- const { title, doc } = resolved.slice;
292
+ const promptMode = prompt !== null;
293
+ // A prompt slice has no tracker row: the title is the prompt, and the record file —
294
+ // not `planDone` — is what `finish` and `abandon` will read.
295
+ let title;
296
+ let doc;
297
+ if (promptMode) {
298
+ title = prompt;
299
+ doc = prompt;
300
+ } else {
301
+ const resolved = await resolveSlice(main, id);
302
+ if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
303
+ ({ title, doc } = resolved.slice);
304
+ }
251
305
  const { branch, worktree } = places(main, id);
252
306
  // The brief lives beside the worktree, not in it: a file inside the worktree
253
307
  // is untracked content in the slice's own tree, and the tree id must name
@@ -256,17 +310,29 @@ async function startSlice(root, id, flags) {
256
310
  const leaseRel = sliceLeaseRel(main, worktree);
257
311
  const session = sliceSession(flags);
258
312
  const task = `${id}: ${String(title ?? '').trim()}`;
259
- const apply = `run: ${CLI} slice start ${id} --yes`;
313
+ const apply = promptMode
314
+ ? `run: ${CLI} slice start -- ${JSON.stringify(prompt)} --yes`
315
+ : `run: ${CLI} slice start ${id} --yes`;
316
+ const record = recordPath(main, id);
260
317
 
261
- const started = await startedState(main);
262
- if (started.error) return fail(started.error, EXIT.USAGE, started.remedy);
263
318
  // The row first, dry, against the main checkout: it is the only part of `start`
264
319
  // that can refuse for a reason about the tracker rather than about git, and a
265
320
  // preview that discovers it late is a preview that half-described the work.
266
- const preview = await planDone(main, id, { state: started.state, dryRun: true });
267
- if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
321
+ // A prompt slice has no row to flip, so there is nothing to preview here.
322
+ const started = promptMode ? { state: null } : await startedState(main);
323
+ if (started.error) return fail(started.error, EXIT.USAGE, started.remedy);
324
+ const preview = promptMode
325
+ ? null
326
+ : await planDone(main, id, { state: started.state, dryRun: true });
327
+ if (preview !== null && preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
268
328
 
269
329
  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)`;
330
+ const stateLine = promptMode
331
+ ? `state (no tracker row; recorded at ${relative(main, record)})`
332
+ : `state ${preview.from} -> ${preview.to}`;
333
+ const stateJson = promptMode
334
+ ? { row: false, record: relative(main, record) }
335
+ : { from: preview.from, to: preview.to, changed: preview.wouldChange };
270
336
 
271
337
  if (!mode.apply) {
272
338
  if (isJson(flags)) {
@@ -281,7 +347,7 @@ async function startSlice(root, id, flags) {
281
347
  worktree: relative(main, worktree),
282
348
  brief: { path: relative(main, briefPath), bytes: null },
283
349
  lease: session === null ? null : { slice: id, path: leaseRel, session },
284
- state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
350
+ state: stateJson,
285
351
  remedy: apply,
286
352
  });
287
353
  return EXIT.OK;
@@ -293,7 +359,7 @@ async function startSlice(root, id, flags) {
293
359
  `worktree ${relative(main, worktree)}`,
294
360
  `brief ${relative(main, briefPath)} (assembled on apply)`,
295
361
  leaseLine,
296
- `state ${preview.from} -> ${preview.to}`,
362
+ stateLine,
297
363
  `apply ${apply}`,
298
364
  ];
299
365
  emit(lines.join('\n'));
@@ -342,9 +408,17 @@ async function startSlice(root, id, flags) {
342
408
  await mkdir(dirname(briefPath), { recursive: true });
343
409
  await writeFile(briefPath, packet.packed.text, 'utf8');
344
410
 
345
- const row = await planDone(main, id, { state: started.state, dryRun: false });
346
- if (row.error) {
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`);
411
+ // Normal slices flip the tracker row; prompt slices write the record file that stands
412
+ // in for it. Both are the "started" mark, and both land after the worktree exists so a
413
+ // failed `worktree add` leaves neither behind.
414
+ let row = null;
415
+ if (promptMode) {
416
+ await writeSliceRecord(main, id, { id, title, kind: 'prompt', createdAt: new Date().toISOString() });
417
+ } else {
418
+ row = await planDone(main, id, { state: started.state, dryRun: false });
419
+ if (row.error) {
420
+ return fail(`${branch} exists with its brief, but the tracker row was not updated: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
421
+ }
348
422
  }
349
423
 
350
424
  const bytes = Buffer.byteLength(packet.packed.text);
@@ -360,7 +434,9 @@ async function startSlice(root, id, flags) {
360
434
  worktree: relative(main, worktree),
361
435
  brief: { path: relative(main, briefPath), bytes },
362
436
  lease,
363
- state: { from: row.from, to: row.to, changed: row.changed },
437
+ state: promptMode
438
+ ? { row: false, record: relative(main, record) }
439
+ : { from: row.from, to: row.to, changed: row.changed },
364
440
  });
365
441
  return EXIT.OK;
366
442
  }
@@ -371,7 +447,9 @@ async function startSlice(root, id, flags) {
371
447
  `worktree ${relative(main, worktree)}`,
372
448
  `brief ${relative(main, briefPath)} ${bytes} bytes`,
373
449
  lease === null ? 'lease (none)' : `lease slice ${id} as ${session}`,
374
- `state ${row.from} -> ${row.to}`,
450
+ promptMode
451
+ ? `state (no tracker row; recorded at ${relative(main, record)})`
452
+ : `state ${row.from} -> ${row.to}`,
375
453
  ];
376
454
  emit(lines.join('\n'));
377
455
  return EXIT.OK;
@@ -423,8 +501,16 @@ async function finishSlice(root, id, flags) {
423
501
  const mode = writeMode(flags);
424
502
  const main = await mainCheckout(root);
425
503
  const resolved = await resolveSlice(main, id);
426
- if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
427
- const { title, doc } = resolved.slice;
504
+ // A prompt slice has no tracker row: the record file stands in for it, and the
505
+ // finish commits and releases the lease without flipping a row.
506
+ const record = resolved.error ? await readSliceRecord(main, id) : null;
507
+ if (resolved.error && record === null) {
508
+ return fail(resolved.error, EXIT.USAGE, resolved.remedy);
509
+ }
510
+ const promptMode = record !== null;
511
+ const title = promptMode ? record.title : resolved.slice.title;
512
+ const doc = promptMode ? record.title : resolved.slice.doc;
513
+ const recRel = relative(main, recordPath(main, id));
428
514
  const { branch, worktree } = places(main, id);
429
515
 
430
516
  const type = String(flags.type ?? 'feat');
@@ -448,8 +534,13 @@ async function finishSlice(root, id, flags) {
448
534
  const split = await splitGates(root, declared, manifest.manifest);
449
535
 
450
536
  if (!mode.apply) {
451
- const row = await planDone(main, id, { sha: null, test, dryRun: true });
452
- if (row.error) return fail(row.error, EXIT.USAGE, row.remedy ?? `run: ${CLI} plan status`);
537
+ let row = null;
538
+ if (!promptMode) {
539
+ row = await planDone(main, id, { sha: null, test, dryRun: true });
540
+ if (row.error) return fail(row.error, EXIT.USAGE, row.remedy ?? `run: ${CLI} plan status`);
541
+ }
542
+ const stateLine = promptMode ? `state (no tracker row; recorded at ${recRel})` : `state ${row.from} -> ${row.to}`;
543
+ const stateJson = promptMode ? { row: false, record: recRel } : { from: row.from, to: row.to, changed: row.wouldChange };
453
544
  const gateLine =
454
545
  declared.length === 0
455
546
  ? '(none declared)'
@@ -464,7 +555,7 @@ async function finishSlice(root, id, flags) {
464
555
  subject,
465
556
  staged,
466
557
  gates: { event: 'pre-commit', declared, skipped: split.skipped.map((s) => s.name) },
467
- state: { from: row.from, to: row.to, changed: row.wouldChange },
558
+ state: stateJson,
468
559
  remedy: apply,
469
560
  });
470
561
  return EXIT.OK;
@@ -475,7 +566,7 @@ async function finishSlice(root, id, flags) {
475
566
  `gates pre-commit ${gateLine} (run on apply)`,
476
567
  `staged ${staged.length} file(s)${staged.length === 0 ? ' (finish refuses without one)' : ''}`,
477
568
  `subject ${subject}`,
478
- `state ${row.from} -> ${row.to}`,
569
+ stateLine,
479
570
  `apply ${apply}`,
480
571
  ];
481
572
  emit(lines.join('\n'));
@@ -485,7 +576,7 @@ async function finishSlice(root, id, flags) {
485
576
  // The commit lands on the slice branch or not at all: finishing from `main`
486
577
  // commits main's tree under the slice's subject, which is the defect this
487
578
  // guard closes (review 0003 Q23).
488
- const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => null);
579
+ const head = await gitText(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => null);
489
580
  if (head !== branch) {
490
581
  return fail(
491
582
  `not on the slice branch: HEAD is ${head ?? 'unknown'}, expected ${branch}`,
@@ -563,15 +654,18 @@ async function finishSlice(root, id, flags) {
563
654
  const message = `${subject}\n\n${CO_AUTHOR}`;
564
655
  try {
565
656
  await git(root, ['commit', '-m', message]);
566
- sha = await git(root, ['rev-parse', 'HEAD']);
657
+ sha = await gitText(root, ['rev-parse', 'HEAD']);
567
658
  } catch (error) {
568
659
  const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
569
660
  return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(message)}`);
570
661
  }
571
662
 
572
- const row = await planDone(main, id, { sha, test, dryRun: false });
573
- if (row.error) {
574
- return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
663
+ let row = null;
664
+ if (!promptMode) {
665
+ row = await planDone(main, id, { sha, test, dryRun: false });
666
+ if (row.error) {
667
+ return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
668
+ }
575
669
  }
576
670
 
577
671
  // The slice is done: its lease goes with it. A failure here is a note — the
@@ -590,8 +684,12 @@ async function finishSlice(root, id, flags) {
590
684
  commit: { sha, subject },
591
685
  gates: gates.gates,
592
686
  staged,
593
- evidence: { recorded: row.evidenceRecorded, sha: row.sha, test: row.test, note: row.evidenceNote },
594
- row: { file: relative(main, row.file), line: row.line, from: row.from, to: row.to, changed: row.changed },
687
+ ...(promptMode
688
+ ? { state: { row: false, record: recRel } }
689
+ : {
690
+ evidence: { recorded: row.evidenceRecorded, sha: row.sha, test: row.test, note: row.evidenceNote },
691
+ row: { file: relative(main, row.file), line: row.line, from: row.from, to: row.to, changed: row.changed },
692
+ }),
595
693
  });
596
694
  return EXIT.OK;
597
695
  }
@@ -602,11 +700,15 @@ async function finishSlice(root, id, flags) {
602
700
  `slice ${id} ${doc}`,
603
701
  ...renderGateReport('pre-commit', declared, gates),
604
702
  `commit ${sha} ${subject}`,
605
- `state ${row.from} -> ${row.to}`,
606
- `record ${relative(main, row.file)} line ${row.line} (edited, not staged: commit it with the merge)`,
607
703
  ];
608
- if (row.evidenceRecorded) lines.push(`evidence ${sha}${test ? ` test: ${test}` : ''}`);
609
- else if (row.evidenceNote) lines.push(`note ${row.evidenceNote}`);
704
+ if (promptMode) {
705
+ lines.push(`state (no tracker row; recorded at ${recRel})`);
706
+ } else {
707
+ lines.push(`state ${row.from} -> ${row.to}`);
708
+ lines.push(`record ${relative(main, row.file)} line ${row.line} (edited, not staged: commit it with the merge)`);
709
+ if (row.evidenceRecorded) lines.push(`evidence ${sha}${test ? ` test: ${test}` : ''}`);
710
+ else if (row.evidenceNote) lines.push(`note ${row.evidenceNote}`);
711
+ }
610
712
  emit(lines.join('\n'));
611
713
  return EXIT.OK;
612
714
  }
@@ -622,17 +724,29 @@ async function abandonSlice(root, id, flags) {
622
724
  const mode = writeMode(flags);
623
725
  const main = await mainCheckout(root);
624
726
  const resolved = await resolveSlice(main, id);
625
- if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
626
- const { doc } = resolved.slice;
727
+ // A prompt slice has no tracker row: the record file stands in for it, and the
728
+ // abandon removes the record file where a tracked abandon would reset the row.
729
+ const record = resolved.error ? await readSliceRecord(main, id) : null;
730
+ if (resolved.error && record === null) {
731
+ return fail(resolved.error, EXIT.USAGE, resolved.remedy);
732
+ }
733
+ const promptMode = record !== null;
734
+ const doc = promptMode ? record.title : resolved.slice.doc;
735
+ const recRel = relative(main, recordPath(main, id));
627
736
  const { branch, worktree } = places(main, id);
628
737
  const session = sliceSession(flags);
629
738
  const apply = `run: ${CLI} slice abandon ${id} --yes`;
630
739
 
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`);
740
+ let preview = null;
741
+ if (!promptMode) {
742
+ const loaded = await loadFormat(main);
743
+ if (loaded.error) return fail(loaded.error, EXIT.USAGE, loaded.remedy);
744
+ const readyDisplay = loaded.format.canonical(loaded.format.ready) ?? 'Queued';
745
+ preview = await planDone(main, id, { state: readyDisplay, dryRun: true });
746
+ if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
747
+ }
748
+ const stateLine = promptMode ? `state (no tracker row; removing ${recRel})` : `state ${preview.from} -> ${preview.to}`;
749
+ const stateJson = promptMode ? { row: false, record: recRel } : { from: preview.from, to: preview.to, changed: preview.wouldChange };
636
750
 
637
751
  if (!mode.apply) {
638
752
  if (isJson(flags)) {
@@ -644,7 +758,7 @@ async function abandonSlice(root, id, flags) {
644
758
  dryRun: true,
645
759
  branch,
646
760
  worktree: relative(main, worktree),
647
- state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
761
+ state: stateJson,
648
762
  remedy: apply,
649
763
  });
650
764
  return EXIT.OK;
@@ -653,7 +767,7 @@ async function abandonSlice(root, id, flags) {
653
767
  `repo ${root}`,
654
768
  `slice ${id} ${doc}`,
655
769
  `remove ${relative(main, worktree)} and branch ${branch} (on apply)`,
656
- `state ${preview.from} -> ${preview.to}`,
770
+ stateLine,
657
771
  `apply ${apply}`,
658
772
  ];
659
773
  emit(lines.join('\n'));
@@ -663,7 +777,7 @@ async function abandonSlice(root, id, flags) {
663
777
  // Abandoning from inside the worktree would pull the floor out: the shell's
664
778
  // own directory is the thing being removed. Compared through git, not through
665
779
  // 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);
780
+ const top = await gitText(root, ['rev-parse', '--show-toplevel']).catch(() => null);
667
781
  if (top !== null && (top === worktree || top.startsWith(`${worktree}/`))) {
668
782
  return fail(
669
783
  `cannot abandon ${id} from inside its worktree`,
@@ -688,13 +802,27 @@ async function abandonSlice(root, id, flags) {
688
802
  removed.push(`kept ${branch} (${detail})`);
689
803
  }
690
804
 
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
- );
805
+ let row = null;
806
+ if (promptMode) {
807
+ // No row to reset; the record file is the only mark of the slice, and it goes.
808
+ try {
809
+ await unlink(recordPath(main, id));
810
+ } catch {
811
+ // A record that is already gone is not a second failure: the worktree and
812
+ // branch are what abandonment exists to take down.
813
+ }
814
+ } else {
815
+ const loaded = await loadFormat(main);
816
+ if (loaded.error) return fail(loaded.error, EXIT.USAGE, loaded.remedy);
817
+ const readyDisplay = loaded.format.canonical(loaded.format.ready) ?? 'Queued';
818
+ row = await planDone(main, id, { state: readyDisplay, dryRun: false });
819
+ if (row.error) {
820
+ return fail(
821
+ `the worktree is down, but the tracker row was not updated: ${row.error}`,
822
+ EXIT.FAIL,
823
+ row.remedy ?? `run: ${CLI} plan status`,
824
+ );
825
+ }
698
826
  }
699
827
 
700
828
  const released = await releaseSlice(main, id, session);
@@ -704,6 +832,7 @@ async function abandonSlice(root, id, flags) {
704
832
  : released.error
705
833
  ? `the slice lease could not be released (${released.error}); the TTL reaps it`
706
834
  : 'no slice lease was held';
835
+ const abandonStateJson = promptMode ? { row: false, record: recRel, removed: true } : { from: row.from, to: row.to, changed: row.changed };
707
836
  if (isJson(flags)) {
708
837
  emitJson({
709
838
  ok: true,
@@ -713,11 +842,17 @@ async function abandonSlice(root, id, flags) {
713
842
  branch,
714
843
  worktree: relative(main, worktree),
715
844
  lease: released.released === true ? { slice: id, session } : null,
716
- state: { from: row.from, to: row.to, changed: row.changed },
845
+ state: abandonStateJson,
717
846
  });
718
847
  return EXIT.OK;
719
848
  }
720
- const lines = [`repo ${root}`, `slice ${id} ${doc}`, ...removed, `state ${row.from} -> ${row.to}`, `lease ${leaseLine}`];
849
+ const lines = [
850
+ `repo ${root}`,
851
+ `slice ${id} ${doc}`,
852
+ ...removed,
853
+ promptMode ? `state (no tracker row; removed ${recRel})` : `state ${row.from} -> ${row.to}`,
854
+ `lease ${leaseLine}`,
855
+ ];
721
856
  emit(lines.join('\n'));
722
857
  return EXIT.OK;
723
858
  }