simt-dev 0.6.3 → 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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. 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
  10. 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
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. 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
  15. 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
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -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,40 @@ 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';
40
55
 
41
56
  const exec = promisify(execFile);
42
57
 
43
- /** The state a started slice moves to, spelled the way the tracker template spells it. */
44
- const STARTED = 'In progress';
58
+ /**
59
+ * The state a started slice moves to: the template's `started`-role state, not
60
+ * a hard-coded word. A repository that renamed its states still starts slices,
61
+ * and one that deleted the in-progress state fails closed in `plan done` on a
62
+ * word its own template never defined (review 0003 Q23).
63
+ */
64
+ async function startedState(main) {
65
+ const loaded = await loadFormat(main);
66
+ if (loaded.error) return { error: loaded.error, remedy: loaded.remedy };
67
+ const { format } = loaded;
68
+ return { state: format.startedWrite ?? format.canonical('in-progress') ?? 'In progress' };
69
+ }
45
70
 
46
71
  /**
47
72
  * A Conventional Commits type: one lower-case word, optionally scoped, optionally
@@ -74,12 +99,16 @@ async function mainCheckout(root) {
74
99
  }
75
100
  }
76
101
 
77
- /** The branch and worktree a slice owns. Both derive from the id: one id, one spelling. */
102
+ /**
103
+ * The branch and worktree a slice owns. Both derive from the id: one id, one spelling.
104
+ * The worktree lives under machine-local state (review 0003 Q19): a worktree inside
105
+ * the checkout would be recorded as an embedded gitlink and would change the tree id.
106
+ */
78
107
  function places(main, id) {
79
108
  const slug = id.toLowerCase();
80
109
  return {
81
110
  branch: `slice/${slug}`,
82
- worktree: join(main, STATE_DIR, 'worktrees', slug),
111
+ worktree: join(main, LOCAL_DIR, 'worktrees', slug),
83
112
  };
84
113
  }
85
114
 
@@ -104,6 +133,81 @@ async function resolveSlice(main, id) {
104
133
  return { slice: found.next };
105
134
  }
106
135
 
136
+ /** The session holding the slice, or null when the caller named none: a person
137
+ * at a terminal has no session, and starting without one proceeds unleased
138
+ * rather than refusing the work. */
139
+ function sliceSession(flags) {
140
+ const session = flags.session ?? process.env[SESSION_ENV] ?? null;
141
+ return typeof session === 'string' && session !== '' ? session : null;
142
+ }
143
+
144
+ /**
145
+ * The slice's lease path, in the spelling the pre-edit hook compares: the
146
+ * worktree directory, repository-relative. Entries carry `kind: 'slice'` so a
147
+ * later schema (review 0003 Q74) can tell slice claims from path claims; the
148
+ * v1 reader keeps the extra fields without understanding them.
149
+ */
150
+ function sliceLeaseRel(main, worktree) {
151
+ return canonicalLeasePath(main, worktree, { ignoreCase: coreIgnoreCase(main) });
152
+ }
153
+
154
+ /**
155
+ * Claim the slice for one session: a second session starting the same slice
156
+ * is refused with the holder's name instead of racing it to the worktree.
157
+ * A claim held by this session is refreshed; anything else on the file is
158
+ * left alone.
159
+ */
160
+ async function claimSlice(main, id, leaseRel, session) {
161
+ try {
162
+ return await withLeaseLock(main, async () => {
163
+ const read = await readLeases(main);
164
+ if (read.error !== null) return { error: read.error, path: read.path };
165
+ const held = activeLeases(read);
166
+ const other = held.find(
167
+ (l) => l.session !== session && ((l.kind === 'slice' && l.slice === id) || (leaseRel !== null && l.path === leaseRel)),
168
+ );
169
+ if (other) return { conflict: other };
170
+ const mine = {
171
+ path: leaseRel,
172
+ session,
173
+ kind: 'slice',
174
+ slice: id,
175
+ expiresAt: Date.now() + DEFAULT_TTL_SECONDS * 1000,
176
+ };
177
+ const kept = held.filter((l) => !(l.kind === 'slice' && l.slice === id && l.session === session));
178
+ const path = await writeLeases(main, [...kept, mine]);
179
+ return { mine, path };
180
+ });
181
+ } catch (error) {
182
+ return { error: error.message };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Release this session's claim on the slice. Best-effort by design: `finish`
188
+ * calls it after the commit exists, so a lease failure is a note about a TTL
189
+ * that will clean itself up, never a second failure stacked on a success.
190
+ */
191
+ async function releaseSlice(main, id, session) {
192
+ if (session === null) return { released: false };
193
+ try {
194
+ return await withLeaseLock(main, async () => {
195
+ const read = await readLeases(main);
196
+ if (read.error !== null) return { error: read.error };
197
+ const held = activeLeases(read);
198
+ const mine = held.filter((l) => l.kind === 'slice' && l.slice === id && l.session === session);
199
+ if (mine.length === 0) return { released: false };
200
+ await writeLeases(
201
+ main,
202
+ held.filter((l) => !mine.includes(l)),
203
+ );
204
+ return { released: true };
205
+ });
206
+ } catch (error) {
207
+ return { error: error.message };
208
+ }
209
+ }
210
+
107
211
  export async function sliceCommand(sub, args, flags) {
108
212
  const root = flags.root ?? process.cwd();
109
213
  if (sub === 'start') {
@@ -116,7 +220,16 @@ export async function sliceCommand(sub, args, flags) {
116
220
  if (!id) return fail(`${CLI} slice finish needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
117
221
  return finishSlice(root, id, flags);
118
222
  }
119
- return fail(`unknown slice subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} slice start <id> | ${CLI} slice finish <id>`);
223
+ if (sub === 'abandon') {
224
+ const id = args[0];
225
+ if (!id) return fail(`${CLI} slice abandon needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
226
+ return abandonSlice(root, id, flags);
227
+ }
228
+ return fail(
229
+ `unknown slice subcommand ${sub}`,
230
+ EXIT.USAGE,
231
+ `run: ${CLI} slice start <id> | ${CLI} slice finish <id> | ${CLI} slice abandon <id>`,
232
+ );
120
233
  }
121
234
 
122
235
  async function startSlice(root, id, flags) {
@@ -126,16 +239,25 @@ async function startSlice(root, id, flags) {
126
239
  if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
127
240
  const { title, doc } = resolved.slice;
128
241
  const { branch, worktree } = places(main, id);
129
- const briefPath = join(worktree, STATE_DIR, 'brief.md');
242
+ // The brief lives beside the worktree, not in it: a file inside the worktree
243
+ // is untracked content in the slice's own tree, and the tree id must name
244
+ // the code, not the briefing (review 0003 Q19, Q23).
245
+ const briefPath = join(main, LOCAL_DIR, 'briefs', `${id.toLowerCase()}.md`);
246
+ const leaseRel = sliceLeaseRel(main, worktree);
247
+ const session = sliceSession(flags);
130
248
  const task = `${id}: ${String(title ?? '').trim()}`;
131
249
  const apply = `run: ${CLI} slice start ${id} --yes`;
132
250
 
251
+ const started = await startedState(main);
252
+ if (started.error) return fail(started.error, EXIT.USAGE, started.remedy);
133
253
  // The row first, dry, against the main checkout: it is the only part of `start`
134
254
  // that can refuse for a reason about the tracker rather than about git, and a
135
255
  // preview that discovers it late is a preview that half-described the work.
136
- const preview = await planDone(main, id, { state: STARTED, dryRun: true });
256
+ const preview = await planDone(main, id, { state: started.state, dryRun: true });
137
257
  if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
138
258
 
259
+ 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)`;
260
+
139
261
  if (!mode.apply) {
140
262
  if (isJson(flags)) {
141
263
  emitJson({
@@ -148,6 +270,7 @@ async function startSlice(root, id, flags) {
148
270
  branch,
149
271
  worktree: relative(main, worktree),
150
272
  brief: { path: relative(main, briefPath), bytes: null },
273
+ lease: session === null ? null : { slice: id, path: leaseRel, session },
151
274
  state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
152
275
  remedy: apply,
153
276
  });
@@ -159,6 +282,7 @@ async function startSlice(root, id, flags) {
159
282
  `branch ${branch}`,
160
283
  `worktree ${relative(main, worktree)}`,
161
284
  `brief ${relative(main, briefPath)} (assembled on apply)`,
285
+ leaseLine,
162
286
  `state ${preview.from} -> ${preview.to}`,
163
287
  `apply ${apply}`,
164
288
  ];
@@ -178,6 +302,26 @@ async function startSlice(root, id, flags) {
178
302
  return fail(`the index at ${main} is empty and will not build here`, EXIT.UNSUPPORTED, `run: ${CLI} index`);
179
303
  }
180
304
 
305
+ // Claimed before the worktree exists: a second session starting the same slice
306
+ // is refused here, not after a worktree that then has to be taken back down.
307
+ let lease = null;
308
+ if (session !== null) {
309
+ const claimed = await claimSlice(main, id, leaseRel, session);
310
+ if (claimed.error) {
311
+ return fail(`the slice could not be leased: ${claimed.error}`, EXIT.FAIL, `run: ${CLI} lease to see every claim`);
312
+ }
313
+ if (claimed.conflict) {
314
+ const holder = claimed.conflict.session;
315
+ const until = new Date(claimed.conflict.expiresAt).toISOString();
316
+ return fail(
317
+ `${id} is already started by session ${holder} (lease until ${until})`,
318
+ EXIT.FAIL,
319
+ `wait for the TTL or ask ${holder} to finish or abandon it; run: ${CLI} lease to see every claim`,
320
+ );
321
+ }
322
+ lease = claimed.mine;
323
+ }
324
+
181
325
  try {
182
326
  await git(main, ['worktree', 'add', '-b', branch, worktree]);
183
327
  } catch (error) {
@@ -185,15 +329,16 @@ async function startSlice(root, id, flags) {
185
329
  return fail(`the worktree could not be created: ${detail}`, EXIT.FAIL, `run: git -C ${main} worktree list`);
186
330
  }
187
331
 
188
- await mkdir(join(worktree, STATE_DIR), { recursive: true });
332
+ await mkdir(dirname(briefPath), { recursive: true });
189
333
  await writeFile(briefPath, packet.packed.text, 'utf8');
190
334
 
191
- const row = await planDone(main, id, { state: STARTED, dryRun: false });
335
+ const row = await planDone(main, id, { state: started.state, dryRun: false });
192
336
  if (row.error) {
193
337
  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
338
  }
195
339
 
196
340
  const bytes = Buffer.byteLength(packet.packed.text);
341
+ if (session === null) note('note no session: the slice is not leased; a second start will not see this one');
197
342
  if (isJson(flags)) {
198
343
  emitJson({
199
344
  ok: true,
@@ -204,6 +349,7 @@ async function startSlice(root, id, flags) {
204
349
  branch,
205
350
  worktree: relative(main, worktree),
206
351
  brief: { path: relative(main, briefPath), bytes },
352
+ lease,
207
353
  state: { from: row.from, to: row.to, changed: row.changed },
208
354
  });
209
355
  return EXIT.OK;
@@ -214,18 +360,62 @@ async function startSlice(root, id, flags) {
214
360
  `branch ${branch}`,
215
361
  `worktree ${relative(main, worktree)}`,
216
362
  `brief ${relative(main, briefPath)} ${bytes} bytes`,
363
+ lease === null ? 'lease (none)' : `lease slice ${id} as ${session}`,
217
364
  `state ${row.from} -> ${row.to}`,
218
365
  ];
219
366
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
220
367
  return EXIT.OK;
221
368
  }
222
369
 
370
+ /**
371
+ * Split the declared gates into the ones that must run and the ones the ledger
372
+ * already proves: a gate with a passing entry on exactly this staged tree, and
373
+ * no later failure for the same gate on the same tree, does not run again.
374
+ * That is the double gate the review verified — the dispatcher, then the
375
+ * commit hook — and the seed of the gate-run cache (review 0003 Q23).
376
+ *
377
+ * Fail-closed throughout: no tree, no ledger, a version mismatch, or an
378
+ * unknown gate all mean the gate runs. An entry recorded under another binary
379
+ * is not evidence for this one, so a pin mismatch disables the cache and
380
+ * routes through `runGates`, which fails on the mismatch itself.
381
+ */
382
+ async function splitGates(root, declared, manifestDoc) {
383
+ if (!Array.isArray(declared) || declared.length === 0) return { fresh: [], skipped: [], tree: null };
384
+ if (assertVersionPin(manifestDoc)) return { fresh: [...declared], skipped: [], tree: null };
385
+ const stagedTree = await indexTreeId(root).catch(() => null);
386
+ if (stagedTree === null) return { fresh: [...declared], skipped: [], tree: null };
387
+ const { entries } = await readLedger(root).catch(() => ({ entries: [] }));
388
+ const fresh = [];
389
+ const skipped = [];
390
+ for (const name of declared) {
391
+ if (GATES[name] === undefined) {
392
+ fresh.push(name);
393
+ continue;
394
+ }
395
+ const pass = passEntryForGate(entries, name, stagedTree);
396
+ if (pass === null) fresh.push(name);
397
+ else skipped.push({ name, at: pass.ts });
398
+ }
399
+ return { fresh, skipped, tree: stagedTree };
400
+ }
401
+
402
+ /** A gate result that did not run: the ledger line is the evidence. */
403
+ function cachedResult({ name, at }) {
404
+ return {
405
+ name,
406
+ status: 'passed',
407
+ detail: `already passed on this tree at ${at} (ledger); not re-run`,
408
+ cached: true,
409
+ };
410
+ }
411
+
223
412
  async function finishSlice(root, id, flags) {
224
413
  const mode = writeMode(flags);
225
414
  const main = await mainCheckout(root);
226
415
  const resolved = await resolveSlice(main, id);
227
416
  if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
228
417
  const { title, doc } = resolved.slice;
418
+ const { branch, worktree } = places(main, id);
229
419
 
230
420
  const type = String(flags.type ?? 'feat');
231
421
  if (!TYPE.test(type)) {
@@ -245,10 +435,15 @@ async function finishSlice(root, id, flags) {
245
435
  const apply = `run: ${CLI} slice finish ${id}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
246
436
 
247
437
  const staged = await stagedFiles(root);
438
+ const split = await splitGates(root, declared, manifest.manifest);
248
439
 
249
440
  if (!mode.apply) {
250
441
  const row = await planDone(main, id, { sha: null, test, dryRun: true });
251
442
  if (row.error) return fail(row.error, EXIT.USAGE, row.remedy ?? `run: ${CLI} plan status`);
443
+ const gateLine =
444
+ declared.length === 0
445
+ ? '(none declared)'
446
+ : `${declared.join(', ')}${split.skipped.length > 0 ? ` (already passed on this tree: ${split.skipped.map((s) => s.name).join(', ')})` : ''}`;
252
447
  if (isJson(flags)) {
253
448
  emitJson({
254
449
  ok: true,
@@ -258,7 +453,7 @@ async function finishSlice(root, id, flags) {
258
453
  dryRun: true,
259
454
  subject,
260
455
  staged,
261
- gates: { event: 'pre-commit', declared },
456
+ gates: { event: 'pre-commit', declared, skipped: split.skipped.map((s) => s.name) },
262
457
  state: { from: row.from, to: row.to, changed: row.wouldChange },
263
458
  remedy: apply,
264
459
  });
@@ -267,7 +462,7 @@ async function finishSlice(root, id, flags) {
267
462
  const lines = [
268
463
  `repo ${root}`,
269
464
  `slice ${id} ${doc}`,
270
- `gates pre-commit ${declared.length > 0 ? declared.join(', ') : '(none declared)'} (run on apply)`,
465
+ `gates pre-commit ${gateLine} (run on apply)`,
271
466
  `staged ${staged.length} file(s)${staged.length === 0 ? ' (finish refuses without one)' : ''}`,
272
467
  `subject ${subject}`,
273
468
  `state ${row.from} -> ${row.to}`,
@@ -277,15 +472,62 @@ async function finishSlice(root, id, flags) {
277
472
  return EXIT.OK;
278
473
  }
279
474
 
475
+ // The commit lands on the slice branch or not at all: finishing from `main`
476
+ // commits main's tree under the slice's subject, which is the defect this
477
+ // guard closes (review 0003 Q23).
478
+ const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => null);
479
+ if (head !== branch) {
480
+ return fail(
481
+ `not on the slice branch: HEAD is ${head ?? 'unknown'}, expected ${branch}`,
482
+ EXIT.USAGE,
483
+ `move to the slice worktree (${relative(main, worktree)}) and rerun: ${apply}`,
484
+ );
485
+ }
486
+
280
487
  if (staged.length === 0) {
281
488
  return fail(`${root} has nothing staged to commit`, EXIT.USAGE, `stage what the slice changed, then rerun: ${apply}`);
282
489
  }
283
490
 
284
491
  // The gates the hook would run, run here. This command is the one place a commit
285
492
  // 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);
493
+ // over the same event, in the checkout that holds the staged diff — except the
494
+ // gates the ledger already proves for this tree, which are reported, not re-run.
495
+ const manifestForGates =
496
+ split.fresh.length === declared.length
497
+ ? manifest.manifest
498
+ : { ...manifest.manifest, gates: { ...(manifest.manifest?.gates ?? {}), preCommit: split.fresh } };
499
+ const freshGates =
500
+ split.fresh.length === 0 && declared.length > 0
501
+ ? { event: 'pre-commit', gates: [], failed: false, notes: [] }
502
+ : await runGates('pre-commit', { root, manifest: manifestForGates });
503
+ if (freshGates.error) return fail(freshGates.error, EXIT.USAGE);
504
+
505
+ // A gate that rewrites the tree (the formatter restaging) moves the commit
506
+ // off the tree the skipped gates passed on. Their evidence is stale, so they
507
+ // run once on the new tree; those results stand, the way fresh results do.
508
+ let skipped = split.skipped;
509
+ if (skipped.length > 0 && split.tree !== null) {
510
+ const afterTree = await indexTreeId(root).catch(() => null);
511
+ if (afterTree !== null && afterTree !== split.tree) {
512
+ const { entries } = await readLedger(root).catch(() => ({ entries: [] }));
513
+ const stale = skipped.filter((s) => passEntryForGate(entries, s.name, afterTree) === null);
514
+ if (stale.length > 0) {
515
+ const rerun = await runGates('pre-commit', {
516
+ root,
517
+ manifest: { ...manifest.manifest, gates: { ...(manifest.manifest?.gates ?? {}), preCommit: stale.map((s) => s.name) } },
518
+ });
519
+ if (rerun.error) return fail(rerun.error, EXIT.USAGE);
520
+ freshGates.gates.push(...rerun.gates);
521
+ if (rerun.failed) freshGates.failed = true;
522
+ for (const n of rerun.notes ?? []) freshGates.notes.push(n);
523
+ skipped = skipped.filter((s) => !stale.some((t) => t.name === s.name));
524
+ }
525
+ }
526
+ }
527
+
528
+ const byName = new Map(freshGates.gates.map((g) => [g.name, g]));
529
+ const combined = declared.map((name) => byName.get(name) ?? cachedResult(skipped.find((s) => s.name === name)));
530
+ const gates = { ...freshGates, gates: combined };
289
531
 
290
532
  if (gates.failed) {
291
533
  const failed = gates.gates.filter((g) => g.status === 'failed').map((g) => g.name).join(', ');
@@ -321,6 +563,12 @@ async function finishSlice(root, id, flags) {
321
563
  return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
322
564
  }
323
565
 
566
+ // The slice is done: its lease goes with it. A failure here is a note — the
567
+ // commit exists, and a lease the TTL will reap is not a second failure.
568
+ const released = await releaseSlice(main, id, sliceSession(flags));
569
+ if (released.error) note(`note the slice lease could not be released (${released.error}); the TTL reaps it`);
570
+
571
+ for (const s of skipped) note(`note ${s.name}: already passed on this tree; not re-run`);
324
572
  if (isJson(flags)) {
325
573
  emitJson({
326
574
  ok: true,
@@ -351,3 +599,114 @@ async function finishSlice(root, id, flags) {
351
599
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
352
600
  return EXIT.OK;
353
601
  }
602
+
603
+ /**
604
+ * `simt slice abandon <id>`: take the slice back down. The worktree is
605
+ * removed, the branch deleted, and the tracker row returns to the ready
606
+ * state — abandonment is a state the tracker can see, not a worktree that
607
+ * silently rots. Preview names everything that would be destroyed; `--yes`
608
+ * is the consent to destroy it (review 0003 Q23).
609
+ */
610
+ async function abandonSlice(root, id, flags) {
611
+ const mode = writeMode(flags);
612
+ const main = await mainCheckout(root);
613
+ const resolved = await resolveSlice(main, id);
614
+ if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
615
+ const { doc } = resolved.slice;
616
+ const { branch, worktree } = places(main, id);
617
+ const session = sliceSession(flags);
618
+ const apply = `run: ${CLI} slice abandon ${id} --yes`;
619
+
620
+ const loaded = await loadFormat(main);
621
+ if (loaded.error) return fail(loaded.error, EXIT.USAGE, loaded.remedy);
622
+ const readyDisplay = loaded.format.canonical(loaded.format.ready) ?? 'Queued';
623
+ const preview = await planDone(main, id, { state: readyDisplay, dryRun: true });
624
+ if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
625
+
626
+ if (!mode.apply) {
627
+ if (isJson(flags)) {
628
+ emitJson({
629
+ ok: true,
630
+ command: 'slice abandon',
631
+ root,
632
+ id,
633
+ dryRun: true,
634
+ branch,
635
+ worktree: relative(main, worktree),
636
+ state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
637
+ remedy: apply,
638
+ });
639
+ return EXIT.OK;
640
+ }
641
+ const lines = [
642
+ `repo ${root}`,
643
+ `slice ${id} ${doc}`,
644
+ `remove ${relative(main, worktree)} and branch ${branch} (on apply)`,
645
+ `state ${preview.from} -> ${preview.to}`,
646
+ `apply ${apply}`,
647
+ ];
648
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
649
+ return EXIT.OK;
650
+ }
651
+
652
+ // Abandoning from inside the worktree would pull the floor out: the shell's
653
+ // own directory is the thing being removed. Compared through git, not through
654
+ // strings, because the caller's spelling of the path may differ in symlinks.
655
+ const top = await git(root, ['rev-parse', '--show-toplevel']).catch(() => null);
656
+ if (top !== null && (top === worktree || top.startsWith(`${worktree}/`))) {
657
+ return fail(
658
+ `cannot abandon ${id} from inside its worktree`,
659
+ EXIT.USAGE,
660
+ `move to the main checkout (${main}) and rerun: ${apply}`,
661
+ );
662
+ }
663
+
664
+ const removed = [];
665
+ try {
666
+ await git(main, ['worktree', 'remove', '--force', worktree]);
667
+ removed.push(`removed ${relative(main, worktree)}`);
668
+ } catch (error) {
669
+ const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
670
+ removed.push(`kept ${relative(main, worktree)} (${detail})`);
671
+ }
672
+ try {
673
+ await git(main, ['branch', '-D', branch]);
674
+ removed.push(`deleted ${branch}`);
675
+ } catch (error) {
676
+ const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
677
+ removed.push(`kept ${branch} (${detail})`);
678
+ }
679
+
680
+ const row = await planDone(main, id, { state: readyDisplay, dryRun: false });
681
+ if (row.error) {
682
+ return fail(
683
+ `the worktree is down, but the tracker row was not updated: ${row.error}`,
684
+ EXIT.FAIL,
685
+ row.remedy ?? `run: ${CLI} plan status`,
686
+ );
687
+ }
688
+
689
+ const released = await releaseSlice(main, id, session);
690
+ const leaseLine =
691
+ released.released === true
692
+ ? `released the slice lease`
693
+ : released.error
694
+ ? `the slice lease could not be released (${released.error}); the TTL reaps it`
695
+ : 'no slice lease was held';
696
+ if (isJson(flags)) {
697
+ emitJson({
698
+ ok: true,
699
+ command: 'slice abandon',
700
+ root,
701
+ id,
702
+ branch,
703
+ worktree: relative(main, worktree),
704
+ lease: released.released === true ? { slice: id, session } : null,
705
+ state: { from: row.from, to: row.to, changed: row.changed },
706
+ });
707
+ return EXIT.OK;
708
+ }
709
+ const lines = [`repo ${root}`, `slice ${id} ${doc}`, ...removed, `state ${row.from} -> ${row.to}`, `lease ${leaseLine}`];
710
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
711
+ return EXIT.OK;
712
+ }
@@ -25,15 +25,14 @@ import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManif
25
25
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
26
26
  import { readCapability } from '../hooks/capability.mjs';
27
27
  import { surfacesLine } from '../projection/surfaces.mjs';
28
- import { CLI } from '../name.mjs';
29
- import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
28
+ import { CLI, LOCK_FILE, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
30
29
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
31
30
  import { resolvePacks } from '../packs.mjs';
32
31
  import { commitJournal, journaledWriteFile } from '../journal.mjs';
33
32
  import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
34
- import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
33
+ import { computeLock, readLock } from '../projection/lock.mjs';
35
34
  import { runDeclaredChecks } from '../kernel/checks.mjs';
36
- import { DECISIONS_DIR } from '../decisions/index.mjs';
35
+ import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
37
36
  import { lintDecisions } from '../memory/decide.mjs';
38
37
 
39
38
  /** Findings `sync` reports and does not fail on.
@@ -64,6 +63,12 @@ export const SYNC_NON_BLOCKING = new Set([
64
63
  'budget-cut',
65
64
  'file-lines-warn',
66
65
  'check-unbuilt',
66
+ // Review 0003 Q28: the tsconfig strictness findings. The base file is repo-owned —
67
+ // init no longer writes it, so neither can `sync` — and both findings' remedies are
68
+ // a decision about the repo's own compiler policy, not a missing file. Blocking on
69
+ // them would fail every fresh TypeScript adoption over a file the tool does not write.
70
+ 'inert',
71
+ 'inconsistent',
67
72
  ]);
68
73
 
69
74
  /**
@@ -101,7 +106,21 @@ export const SYNC_NON_BLOCKING = new Set([
101
106
  // (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
102
107
  // remedy is a decision about the number — and a cut the repository asked for by declaring
103
108
  // a budget is the tool working, not a repository in need of a file.
104
- export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget', 'budget-cut', 'file-lines-warn', 'check-unbuilt']);
109
+ // `inert` and `inconsistent` are here for review 0003 Q28: the base file is repo-owned,
110
+ // so the gate reports the state of the repo's own compiler policy without failing it.
111
+ // A fresh TypeScript adoption has neither file in a state the tool can repair, and a
112
+ // gate that reddens on a policy decision is one the repo satisfies by choosing, not by
113
+ // running a command.
114
+ export const CHECK_NON_BLOCKING = new Set([
115
+ 'orphaned',
116
+ 'pack-unpinned',
117
+ 'budget',
118
+ 'budget-cut',
119
+ 'file-lines-warn',
120
+ 'check-unbuilt',
121
+ 'inert',
122
+ 'inconsistent',
123
+ ]);
105
124
 
106
125
  export async function syncCommand(positional, flags) {
107
126
  const root = flags.root ?? positional[0] ?? process.cwd();
@@ -153,6 +172,10 @@ export async function syncCommand(positional, flags) {
153
172
  );
154
173
  }
155
174
 
175
+ // A dangling cross-reference does not stop the projection: the region renders, and one
176
+ // unreachable reference is a defect to report, not a reason to withhold the rest.
177
+ for (const p of result.crossRefs ?? []) note(`PROBLEM ${p}`);
178
+
156
179
  const broken = result.targets.filter((t) => t.action === 'malformed');
157
180
  if (broken.length > 0) {
158
181
  const first = broken[0];
@@ -307,23 +330,13 @@ export async function syncCommand(positional, flags) {
307
330
  // mkdir; `sync` did not, so the first `sync` after a surface was added failed with
308
331
  // ENOENT on the second target and had already written the first. The parent is
309
332
  // derived from the target, never from the surface table, so a future surface with a
310
- // deeper path needs no change here.
333
+ // deeper path needs no change here. The lock is one of these targets (Q29): it
334
+ // reaches this loop like every other file, so a deleted lock is a write the plan
335
+ // shows and the journal records.
311
336
  await mkdir(dirname(t.path), { recursive: true });
312
337
  await journaledWriteFile(root, journal, t.path, t.text);
313
338
  note(`wrote ${t.file}`);
314
339
  }
315
- const lock = await computeLock(root, flags);
316
- const lockText = `${JSON.stringify(lock, null, 2)}\n`;
317
- let previousLock = null;
318
- try {
319
- previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
320
- } catch (error) {
321
- if (error.code !== 'ENOENT') throw error;
322
- }
323
- if (previousLock !== lockText) {
324
- await journaledWriteFile(root, journal, join(root, LOCK_FILE), lockText);
325
- note(`wrote ${LOCK_FILE}`);
326
- }
327
340
  await commitJournal(root, 'sync', journal);
328
341
  // The regions moved, so the last `check` no longer describes this tree.
329
342
  // Invalidated rather than refreshed: `sync` does not run gates, checkers
@@ -601,13 +614,15 @@ export async function checkRepo(root, opts = {}) {
601
614
  const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
602
615
  findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
603
616
 
604
- const decisionsDir = join(root, 'docs', 'decisions');
605
- const decisionLint = await lintDecisions(decisionsDir, {
606
- allowHistorical: resolve(decisionsDir) === DECISIONS_DIR,
617
+ // Q55: one resolution shared with `decide --check`, so the two commands
618
+ // cannot disagree about whose records these are or which are historical.
619
+ const relDecisionsDir = await decisionsDir(root);
620
+ const decisionLint = await lintDecisions(join(root, relDecisionsDir), {
621
+ historicalCutoff: await decisionsCutoff(root),
607
622
  });
608
623
  findings.push(
609
624
  ...decisionLint.findings.map((f) => ({
610
- file: ['docs', 'decisions', f.file].join('/'),
625
+ file: [relDecisionsDir, f.file].join('/'),
611
626
  status: f.status,
612
627
  detail: f.detail,
613
628
  remedy: f.remedy,