simt-dev 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- 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
- 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
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- 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
- 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
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +82 -21
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// `simt slice start` and `simt slice finish`: one tracker row, one branch.
|
|
2
|
+
//
|
|
3
|
+
// A row in a tracker is a promise, and these are the two moments it is acted on.
|
|
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 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
|
+
// the declared gates run through the shared dispatcher first, because a command that
|
|
9
|
+
// commits for you may not assume a hook is installed to stop it, then the commit
|
|
10
|
+
// carries a Conventional subject built from the row's own title, and only then does
|
|
11
|
+
// the tracker learn the SHA and the test command that closed it — the R31 row
|
|
12
|
+
// machinery, called, never reimplemented.
|
|
13
|
+
//
|
|
14
|
+
// Two orders here are contracts rather than preferences:
|
|
15
|
+
//
|
|
16
|
+
// start: worktree, brief, then row. Everything the row would claim exists before it
|
|
17
|
+
// claims it, so a failed `worktree add` leaves the tracker untouched.
|
|
18
|
+
//
|
|
19
|
+
// finish: gates, commit, row. The row records a SHA that only exists after the
|
|
20
|
+
// commit, and if the row write then fails the message says which half happened —
|
|
21
|
+
// the commit exists, the tracker does not know it — because a silent half-state is
|
|
22
|
+
// how a tracker starts lying.
|
|
23
|
+
//
|
|
24
|
+
// The tracker itself always lives in the MAIN checkout. `start` flips the row there
|
|
25
|
+
// and `finish` records the evidence there, while the worktree carries only the
|
|
26
|
+
// slice's code: editing the same row in both places would make every merge of a
|
|
27
|
+
// finished slice a conflict on the one line that records it.
|
|
28
|
+
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
31
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
32
|
+
import { promisify } from 'node:util';
|
|
33
|
+
|
|
34
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
35
|
+
import { planDone, planNext } from '../plan/index.mjs';
|
|
36
|
+
import { loadFormat } from '../plan/template.mjs';
|
|
37
|
+
import { assembleBrief } from '../brief/assemble.mjs';
|
|
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';
|
|
52
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
53
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
54
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
55
|
+
|
|
56
|
+
const exec = promisify(execFile);
|
|
57
|
+
|
|
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
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A Conventional Commits type: one lower-case word, optionally scoped, optionally
|
|
73
|
+
* marked breaking. The scope a `finish` builds is the slice id, so this flag is the
|
|
74
|
+
* subject's only free input — a title cannot say whether a slice was a feat, a fix or
|
|
75
|
+
* a chore, and deriving it from the words would be a guess no reader could argue with.
|
|
76
|
+
*/
|
|
77
|
+
const TYPE = /^[a-z]+(?:\([a-z0-9][a-z0-9._-]*\))?!?$/;
|
|
78
|
+
|
|
79
|
+
async function git(root, args) {
|
|
80
|
+
const { stdout } = await exec('git', ['-C', root, ...args], { maxBuffer: 32 << 20 });
|
|
81
|
+
return stdout.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The checkout that owns the tracker: the main worktree when `root` is a linked one.
|
|
86
|
+
*
|
|
87
|
+
* `--git-common-dir` answers `.git` from the main checkout and `<main>/.git` from a
|
|
88
|
+
* worktree, so resolving it against `root` and dropping the last component gives the
|
|
89
|
+
* right answer in both shapes. A directory that is not a git repository at all falls
|
|
90
|
+
* back to `root`: the plan machinery reads it from wherever it was pointed, and the
|
|
91
|
+
* git commands below will have their own say about whether this is a repository.
|
|
92
|
+
*/
|
|
93
|
+
async function mainCheckout(root) {
|
|
94
|
+
try {
|
|
95
|
+
const common = await git(root, ['rev-parse', '--git-common-dir']);
|
|
96
|
+
return dirname(resolve(root, common));
|
|
97
|
+
} catch {
|
|
98
|
+
return root;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
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
|
+
*/
|
|
107
|
+
function places(main, id) {
|
|
108
|
+
const slug = id.toLowerCase();
|
|
109
|
+
return {
|
|
110
|
+
branch: `slice/${slug}`,
|
|
111
|
+
worktree: join(main, LOCAL_DIR, 'worktrees', slug),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The Conventional subject for a finished slice: `<type>(<id>): <title>`.
|
|
117
|
+
*
|
|
118
|
+
* The description is the row's own title, so the message says what the plan said it
|
|
119
|
+
* would do — whitespace collapsed, a trailing period dropped (slice-commit wants an
|
|
120
|
+
* imperative subject with no trailing period), and the id itself as the fallback when
|
|
121
|
+
* a tracker has no title column rather than an empty description after the colon.
|
|
122
|
+
*/
|
|
123
|
+
function subjectFor(type, id, title) {
|
|
124
|
+
const description = String(title ?? '').replace(/\s+/g, ' ').trim().replace(/\.$/, '') || id;
|
|
125
|
+
return `${type}(${id.toLowerCase()}): ${description}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve one named slice through `plan next`, which already knows missing and ambiguous. */
|
|
129
|
+
async function resolveSlice(main, id) {
|
|
130
|
+
const found = await planNext(main, id);
|
|
131
|
+
if (found.error) return { error: found.error, remedy: found.remedy ?? `run: ${CLI} plan status` };
|
|
132
|
+
if (!found.next) return { error: found.reason ?? `${id} has no section`, remedy: `run: ${CLI} plan status` };
|
|
133
|
+
return { slice: found.next };
|
|
134
|
+
}
|
|
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
|
+
|
|
211
|
+
export async function sliceCommand(sub, args, flags) {
|
|
212
|
+
const root = flags.root ?? process.cwd();
|
|
213
|
+
if (sub === 'start') {
|
|
214
|
+
const id = args[0];
|
|
215
|
+
if (!id) return fail(`${CLI} slice start needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
|
|
216
|
+
return startSlice(root, id, flags);
|
|
217
|
+
}
|
|
218
|
+
if (sub === 'finish') {
|
|
219
|
+
const id = args[0];
|
|
220
|
+
if (!id) return fail(`${CLI} slice finish needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
|
|
221
|
+
return finishSlice(root, id, flags);
|
|
222
|
+
}
|
|
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
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function startSlice(root, id, flags) {
|
|
236
|
+
const mode = writeMode(flags);
|
|
237
|
+
const main = await mainCheckout(root);
|
|
238
|
+
const resolved = await resolveSlice(main, id);
|
|
239
|
+
if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
|
|
240
|
+
const { title, doc } = resolved.slice;
|
|
241
|
+
const { branch, worktree } = places(main, id);
|
|
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);
|
|
248
|
+
const task = `${id}: ${String(title ?? '').trim()}`;
|
|
249
|
+
const apply = `run: ${CLI} slice start ${id} --yes`;
|
|
250
|
+
|
|
251
|
+
const started = await startedState(main);
|
|
252
|
+
if (started.error) return fail(started.error, EXIT.USAGE, started.remedy);
|
|
253
|
+
// The row first, dry, against the main checkout: it is the only part of `start`
|
|
254
|
+
// that can refuse for a reason about the tracker rather than about git, and a
|
|
255
|
+
// preview that discovers it late is a preview that half-described the work.
|
|
256
|
+
const preview = await planDone(main, id, { state: started.state, dryRun: true });
|
|
257
|
+
if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
|
|
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
|
+
|
|
261
|
+
if (!mode.apply) {
|
|
262
|
+
if (isJson(flags)) {
|
|
263
|
+
emitJson({
|
|
264
|
+
ok: true,
|
|
265
|
+
command: 'slice start',
|
|
266
|
+
root,
|
|
267
|
+
id,
|
|
268
|
+
title,
|
|
269
|
+
dryRun: true,
|
|
270
|
+
branch,
|
|
271
|
+
worktree: relative(main, worktree),
|
|
272
|
+
brief: { path: relative(main, briefPath), bytes: null },
|
|
273
|
+
lease: session === null ? null : { slice: id, path: leaseRel, session },
|
|
274
|
+
state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
|
|
275
|
+
remedy: apply,
|
|
276
|
+
});
|
|
277
|
+
return EXIT.OK;
|
|
278
|
+
}
|
|
279
|
+
const lines = [
|
|
280
|
+
`repo ${root}`,
|
|
281
|
+
`slice ${id} ${doc}`,
|
|
282
|
+
`branch ${branch}`,
|
|
283
|
+
`worktree ${relative(main, worktree)}`,
|
|
284
|
+
`brief ${relative(main, briefPath)} (assembled on apply)`,
|
|
285
|
+
leaseLine,
|
|
286
|
+
`state ${preview.from} -> ${preview.to}`,
|
|
287
|
+
`apply ${apply}`,
|
|
288
|
+
];
|
|
289
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
290
|
+
return EXIT.OK;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Assemble before anything is created: a packet that cannot be built must leave the
|
|
294
|
+
// repository exactly as it was, rather than a worktree nobody can brief and a row
|
|
295
|
+
// still waiting. `build` defaults on — this is a command a person asked for, not a
|
|
296
|
+
// prompt-submit delivery, so a cold index is built rather than reported.
|
|
297
|
+
const packet = await assembleBrief({ root: main, task });
|
|
298
|
+
if (packet.error) {
|
|
299
|
+
return fail(`the brief for ${id} could not be assembled: ${packet.error}`, EXIT.FAIL, `run: ${CLI} brief ${JSON.stringify(task)}`);
|
|
300
|
+
}
|
|
301
|
+
if (packet.cold) {
|
|
302
|
+
return fail(`the index at ${main} is empty and will not build here`, EXIT.UNSUPPORTED, `run: ${CLI} index`);
|
|
303
|
+
}
|
|
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
|
+
|
|
325
|
+
try {
|
|
326
|
+
await git(main, ['worktree', 'add', '-b', branch, worktree]);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
|
|
329
|
+
return fail(`the worktree could not be created: ${detail}`, EXIT.FAIL, `run: git -C ${main} worktree list`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await mkdir(dirname(briefPath), { recursive: true });
|
|
333
|
+
await writeFile(briefPath, packet.packed.text, 'utf8');
|
|
334
|
+
|
|
335
|
+
const row = await planDone(main, id, { state: started.state, dryRun: false });
|
|
336
|
+
if (row.error) {
|
|
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`);
|
|
338
|
+
}
|
|
339
|
+
|
|
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');
|
|
342
|
+
if (isJson(flags)) {
|
|
343
|
+
emitJson({
|
|
344
|
+
ok: true,
|
|
345
|
+
command: 'slice start',
|
|
346
|
+
root,
|
|
347
|
+
id,
|
|
348
|
+
title,
|
|
349
|
+
branch,
|
|
350
|
+
worktree: relative(main, worktree),
|
|
351
|
+
brief: { path: relative(main, briefPath), bytes },
|
|
352
|
+
lease,
|
|
353
|
+
state: { from: row.from, to: row.to, changed: row.changed },
|
|
354
|
+
});
|
|
355
|
+
return EXIT.OK;
|
|
356
|
+
}
|
|
357
|
+
const lines = [
|
|
358
|
+
`repo ${root}`,
|
|
359
|
+
`slice ${id} ${doc}`,
|
|
360
|
+
`branch ${branch}`,
|
|
361
|
+
`worktree ${relative(main, worktree)}`,
|
|
362
|
+
`brief ${relative(main, briefPath)} ${bytes} bytes`,
|
|
363
|
+
lease === null ? 'lease (none)' : `lease slice ${id} as ${session}`,
|
|
364
|
+
`state ${row.from} -> ${row.to}`,
|
|
365
|
+
];
|
|
366
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
367
|
+
return EXIT.OK;
|
|
368
|
+
}
|
|
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
|
+
|
|
412
|
+
async function finishSlice(root, id, flags) {
|
|
413
|
+
const mode = writeMode(flags);
|
|
414
|
+
const main = await mainCheckout(root);
|
|
415
|
+
const resolved = await resolveSlice(main, id);
|
|
416
|
+
if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
|
|
417
|
+
const { title, doc } = resolved.slice;
|
|
418
|
+
const { branch, worktree } = places(main, id);
|
|
419
|
+
|
|
420
|
+
const type = String(flags.type ?? 'feat');
|
|
421
|
+
if (!TYPE.test(type)) {
|
|
422
|
+
return fail(`${type} is not a Conventional Commits type`, EXIT.USAGE, `example: run: ${CLI} slice finish ${id} --type fix`);
|
|
423
|
+
}
|
|
424
|
+
const subject = subjectFor(type, id, title);
|
|
425
|
+
const test = flags.test ?? null;
|
|
426
|
+
|
|
427
|
+
// A manifest that does not parse fails closed: the gates cannot be read, so nothing
|
|
428
|
+
// may commit through them. No manifest at all is a valid answer — `runGates` turns
|
|
429
|
+
// it into a note and an empty pass, the same way the hook does.
|
|
430
|
+
const manifest = await readManifest(root);
|
|
431
|
+
if (manifest.error) {
|
|
432
|
+
return fail(`${MANIFEST_FILE}: ${manifest.error}`, EXIT.CONFIG, `fix ${MANIFEST_FILE}, or run: ${CLI} init`);
|
|
433
|
+
}
|
|
434
|
+
const declared = manifest.manifest?.gates?.preCommit ?? [];
|
|
435
|
+
const apply = `run: ${CLI} slice finish ${id}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
|
|
436
|
+
|
|
437
|
+
const staged = await stagedFiles(root);
|
|
438
|
+
const split = await splitGates(root, declared, manifest.manifest);
|
|
439
|
+
|
|
440
|
+
if (!mode.apply) {
|
|
441
|
+
const row = await planDone(main, id, { sha: null, test, dryRun: true });
|
|
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(', ')})` : ''}`;
|
|
447
|
+
if (isJson(flags)) {
|
|
448
|
+
emitJson({
|
|
449
|
+
ok: true,
|
|
450
|
+
command: 'slice finish',
|
|
451
|
+
root,
|
|
452
|
+
id,
|
|
453
|
+
dryRun: true,
|
|
454
|
+
subject,
|
|
455
|
+
staged,
|
|
456
|
+
gates: { event: 'pre-commit', declared, skipped: split.skipped.map((s) => s.name) },
|
|
457
|
+
state: { from: row.from, to: row.to, changed: row.wouldChange },
|
|
458
|
+
remedy: apply,
|
|
459
|
+
});
|
|
460
|
+
return EXIT.OK;
|
|
461
|
+
}
|
|
462
|
+
const lines = [
|
|
463
|
+
`repo ${root}`,
|
|
464
|
+
`slice ${id} ${doc}`,
|
|
465
|
+
`gates pre-commit ${gateLine} (run on apply)`,
|
|
466
|
+
`staged ${staged.length} file(s)${staged.length === 0 ? ' (finish refuses without one)' : ''}`,
|
|
467
|
+
`subject ${subject}`,
|
|
468
|
+
`state ${row.from} -> ${row.to}`,
|
|
469
|
+
`apply ${apply}`,
|
|
470
|
+
];
|
|
471
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
472
|
+
return EXIT.OK;
|
|
473
|
+
}
|
|
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
|
+
|
|
487
|
+
if (staged.length === 0) {
|
|
488
|
+
return fail(`${root} has nothing staged to commit`, EXIT.USAGE, `stage what the slice changed, then rerun: ${apply}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The gates the hook would run, run here. This command is the one place a commit
|
|
492
|
+
// happens where a hook could not have stopped it, so it runs the same dispatcher
|
|
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 };
|
|
531
|
+
|
|
532
|
+
if (gates.failed) {
|
|
533
|
+
const failed = gates.gates.filter((g) => g.status === 'failed').map((g) => g.name).join(', ');
|
|
534
|
+
if (isJson(flags)) {
|
|
535
|
+
emitJson({
|
|
536
|
+
ok: false,
|
|
537
|
+
command: 'slice finish',
|
|
538
|
+
root,
|
|
539
|
+
id,
|
|
540
|
+
exitCode: EXIT.FAIL,
|
|
541
|
+
error: `pre-commit gates failed: ${failed}`,
|
|
542
|
+
remedy: `run: ${CLI} hooks run pre-commit --root ${root}`,
|
|
543
|
+
gates: gates.gates,
|
|
544
|
+
});
|
|
545
|
+
return EXIT.FAIL;
|
|
546
|
+
}
|
|
547
|
+
for (const n of gates.notes ?? []) note(`note ${n}`);
|
|
548
|
+
emit(renderGateReport('pre-commit', declared, gates).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
549
|
+
return EXIT.FAIL;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let sha = null;
|
|
553
|
+
try {
|
|
554
|
+
await git(root, ['commit', '-m', subject]);
|
|
555
|
+
sha = await git(root, ['rev-parse', 'HEAD']);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
|
|
558
|
+
return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(subject)}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const row = await planDone(main, id, { sha, test, dryRun: false });
|
|
562
|
+
if (row.error) {
|
|
563
|
+
return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
|
|
564
|
+
}
|
|
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`);
|
|
572
|
+
if (isJson(flags)) {
|
|
573
|
+
emitJson({
|
|
574
|
+
ok: true,
|
|
575
|
+
command: 'slice finish',
|
|
576
|
+
root,
|
|
577
|
+
id,
|
|
578
|
+
title,
|
|
579
|
+
commit: { sha, subject },
|
|
580
|
+
gates: gates.gates,
|
|
581
|
+
staged,
|
|
582
|
+
evidence: { recorded: row.evidenceRecorded, sha: row.sha, test: row.test, note: row.evidenceNote },
|
|
583
|
+
row: { file: relative(main, row.file), line: row.line, from: row.from, to: row.to, changed: row.changed },
|
|
584
|
+
});
|
|
585
|
+
return EXIT.OK;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const n of gates.notes ?? []) note(`note ${n}`);
|
|
589
|
+
const lines = [
|
|
590
|
+
`repo ${root}`,
|
|
591
|
+
`slice ${id} ${doc}`,
|
|
592
|
+
...renderGateReport('pre-commit', declared, gates),
|
|
593
|
+
`commit ${sha} ${subject}`,
|
|
594
|
+
`state ${row.from} -> ${row.to}`,
|
|
595
|
+
`record ${relative(main, row.file)} line ${row.line} (edited, not staged: commit it with the merge)`,
|
|
596
|
+
];
|
|
597
|
+
if (row.evidenceRecorded) lines.push(`evidence ${sha}${test ? ` test: ${test}` : ''}`);
|
|
598
|
+
else if (row.evidenceNote) lines.push(`note ${row.evidenceNote}`);
|
|
599
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
600
|
+
return EXIT.OK;
|
|
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
|
+
}
|