@skitterbyte/skitterspec-linear 10.8.0 → 12.0.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 (40) hide show
  1. package/MIGRATION.md +88 -0
  2. package/README.md +3 -3
  3. package/assets/claude-md-section.md +20 -48
  4. package/assets/core/SETUP.md +1 -1
  5. package/assets/core/env.config.json.example +7 -1
  6. package/assets/core/env.config.md +56 -11
  7. package/assets/core/gating.config.json.example +4 -0
  8. package/assets/core/gating.config.md +81 -0
  9. package/assets/core/linear.config.md +12 -11
  10. package/assets/rules/spec-planning.md +53 -13
  11. package/assets/skills/spec/SKILL.md +46 -18
  12. package/assets/skills/spec-bug/SKILL.md +32 -27
  13. package/assets/skills/spec-cancel/SKILL.md +26 -0
  14. package/assets/skills/spec-complete/SKILL.md +70 -17
  15. package/assets/skills/spec-hotfix/SKILL.md +40 -19
  16. package/assets/skills/spec-init/SKILL.md +31 -8
  17. package/assets/skills/spec-linear-setup/SKILL.md +32 -7
  18. package/assets/skills/spec-next/SKILL.md +141 -0
  19. package/assets/skills/spec-push/SKILL.md +15 -16
  20. package/assets/skills/spec-review/SKILL.md +24 -11
  21. package/assets/skills/spec-start/SKILL.md +226 -0
  22. package/assets/skills/spec-status/SKILL.md +2 -2
  23. package/assets/skills/spec-sync/SKILL.md +8 -8
  24. package/assets/skills/spec-to-main/SKILL.md +21 -19
  25. package/package.json +1 -1
  26. package/src/cli.js +405 -15
  27. package/src/env/classify.js +91 -0
  28. package/src/env/config.js +39 -1
  29. package/src/env/integrate.js +61 -1
  30. package/src/env/live.js +27 -3
  31. package/src/env/provision.js +196 -5
  32. package/src/env/resolve.js +1 -0
  33. package/src/env/teardown.js +41 -3
  34. package/src/gating.js +155 -0
  35. package/src/init.js +45 -9
  36. package/src/prompts.js +41 -4
  37. package/src/vendor/linear/cli-sync.js +22 -2
  38. package/src/vendor/linear/config.js +1 -1
  39. package/src/vendor/sync-core/src/compare.js +25 -2
  40. package/assets/skills/spec-go/SKILL.md +0 -233
@@ -0,0 +1,91 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Which uncommitted paths belong to ONE spec?
5
+ *
6
+ * `/spec-start` refuses a dirty tree because moving another spec's unfinished
7
+ * work is not ours to do. But the commonest dirty tree there is — the spec you
8
+ * just authored and are now starting — is not another spec's work at all, and
9
+ * refusing it costs a round trip through `/commit` on every single start.
10
+ *
11
+ * The distinction this module draws is deliberately NOT "does the dirt look
12
+ * important?" (a judgement, and the gate rightly refuses to make one) but
13
+ * membership in an exactly-known set: the spec's own folder, plus whatever the
14
+ * project declares in `spec.companionPaths`. Everything else is foreign, and one
15
+ * foreign path disqualifies the whole tree.
16
+ */
17
+
18
+ const path = require('node:path')
19
+ const { BUCKETS, expandTokens, readFrontmatterField } = require('./resolve.js')
20
+
21
+ // git reports repo-relative, forward-slashed paths; an untracked directory comes
22
+ // back with a trailing slash. Normalise both away so comparisons are exact.
23
+ function normalize(p) {
24
+ return String(p || '')
25
+ .trim()
26
+ .replace(/\\/g, '/')
27
+ .replace(/\/+$/, '')
28
+ }
29
+
30
+ /**
31
+ * Expand a `companionPaths` pattern for this spec, or return null when it cannot
32
+ * be expanded.
33
+ *
34
+ * A PATTERN THAT REFERENCES `{identifier}` WHEN NO IDENTIFIER RESOLVES MATCHES
35
+ * NOTHING, deliberately. Two ordinary situations produce a spec with no id — a
36
+ * project that never set `branch.identifierField`, and a spec deliberately kept
37
+ * local, never pushed to a tracker — and in both the file the pattern describes
38
+ * either does not exist or belongs to some OTHER spec. Expanding `{identifier}`
39
+ * to a placeholder, or dropping the token, would widen the owned set to a path
40
+ * this spec has no claim on and sweep another spec's snapshot into its commit.
41
+ * Being wrong this way costs a refusal the operator can fix with `/commit`; the
42
+ * other way costs them a file they never staged.
43
+ */
44
+ function expandCompanion(pattern, spec, config) {
45
+ const tokens = { slug: spec.slug }
46
+ if (/\{identifier\}/.test(pattern)) {
47
+ const field = config && config.branch && config.branch.identifierField
48
+ const identifier = readFrontmatterField(spec.path, field)
49
+ if (!identifier) return null
50
+ tokens.identifier = identifier
51
+ }
52
+ return normalize(expandTokens(pattern, tokens))
53
+ }
54
+
55
+ /**
56
+ * Split `dirtyPaths` into the ones that belong to `spec` and the ones that do
57
+ * not. Repo-relative paths in, repo-relative paths out; nothing is read from git
58
+ * and nothing is written.
59
+ *
60
+ * Owned:
61
+ * - anything inside `specs/<bucket>/<folder>/` for ANY bucket. Every bucket is
62
+ * checked rather than the spec's current one because starting a spec MOVES it
63
+ * (backlog → in-progress), so a tree mid-move is dirty in two buckets at once
64
+ * and both halves are the same spec's.
65
+ * - each expandable `spec.companionPaths` entry.
66
+ *
67
+ * @returns {{owned: string[], foreign: string[]}}
68
+ */
69
+ function classifyDirtyTree(spec, dirtyPaths, config) {
70
+ const owned = []
71
+ const foreign = []
72
+ if (!spec) return { owned, foreign: (dirtyPaths || []).map(normalize).filter(Boolean) }
73
+
74
+ const folders = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
75
+ const companions = new Set()
76
+ for (const pattern of (config && config.spec && config.spec.companionPaths) || []) {
77
+ const expanded = expandCompanion(pattern, spec, config)
78
+ if (expanded) companions.add(expanded)
79
+ }
80
+
81
+ for (const raw of dirtyPaths || []) {
82
+ const p = normalize(raw)
83
+ if (!p) continue
84
+ const inFolder = folders.some((f) => p === f || p.startsWith(`${f}/`))
85
+ if (inFolder || companions.has(p)) owned.push(p)
86
+ else foreign.push(p)
87
+ }
88
+ return { owned, foreign }
89
+ }
90
+
91
+ module.exports = { classifyDirtyTree, expandCompanion }
package/src/env/config.js CHANGED
@@ -13,6 +13,9 @@
13
13
  *
14
14
  * Shape (see specs/.core/env.config.md for field docs):
15
15
  * {
16
+ * mode: "worktree" | "checkout", // where a spec's branch is built —
17
+ * // its own worktree (default), or the primary checkout in place.
18
+ * // NOTE: unrelated to `seedFiles.mode`, which is symlink|copy.
16
19
  * worktree: { root, folderPattern },
17
20
  * docker: { enabled, composeFile, projectNamePattern, portBase,
18
21
  * portsPerSpec, envFile, backupCommand },
@@ -27,6 +30,9 @@
27
30
  * open: { command }, // optional, editor/terminal-agnostic opener
28
31
  * registry: ".spec-env/registry.json",
29
32
  * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
33
+ * spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
34
+ * // spec alongside its own folder (provider-neutral; {slug} and
35
+ * // {identifier} expand); empty = the spec folder only
30
36
  * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
31
37
  * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
32
38
  * teardown: { deleteRemoteBranch },
@@ -44,6 +50,16 @@ const { join } = require('node:path')
44
50
  const CONFIG_FILE = join('specs', '.core', 'env.config.json')
45
51
 
46
52
  const DEFAULT_CONFIG = Object.freeze({
53
+ // Where a spec's branch is built. "worktree" gives every spec its own checkout
54
+ // — parallel specs, `main` left free — at the cost of a terminal session per
55
+ // spec. "checkout" builds the branch in the primary checkout instead: one spec
56
+ // at a time, in the terminal you are already sitting in.
57
+ //
58
+ // Defaults to "worktree" so no installed repo changes behaviour on upgrade.
59
+ // Never inferred from whether `dev`/`docker` are configured: a repo with no
60
+ // dev servers may still want parallel specs, and absence of configuration is
61
+ // not evidence of intent (see .claude/rules/negative-checks.md).
62
+ mode: 'worktree',
47
63
  worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
48
64
  docker: Object.freeze({
49
65
  enabled: true,
@@ -77,11 +93,18 @@ const DEFAULT_CONFIG = Object.freeze({
77
93
  // 00-overview.md frontmatter field a provider writes the ticket id into (empty
78
94
  // = no identifier, so patterns referencing {identifier} fall back to type/slug).
79
95
  branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
96
+ // Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
97
+ // — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
98
+ // design: the base engine must not know that any particular tracker exists, so
99
+ // the project declares the shape and `{slug}` / `{identifier}` expand exactly as
100
+ // they do in `branch.pattern` ({identifier} via `branch.identifierField`).
101
+ // Default: none, so a spec owns only its own folder.
102
+ spec: Object.freeze({ companionPaths: Object.freeze([]) }),
80
103
  // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
81
104
  baseBranch: '',
82
105
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
83
106
  // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
84
- // `spec-env down` does about the branch `/spec-go` pushed: "prompt" (default)
107
+ // `spec-env down` does about the branch `/spec-start` pushed: "prompt" (default)
85
108
  // plans the delete in its own confirm-first section for the skill to ask about,
86
109
  // "never" omits it, "always" folds it into the run-blind command list. Only ever
87
110
  // planned for a LANDED branch — see teardown.js.
@@ -105,6 +128,7 @@ function isObject(value) {
105
128
  // A fresh, deeply-mutable copy of the defaults to merge onto.
106
129
  function defaults() {
107
130
  return {
131
+ mode: DEFAULT_CONFIG.mode,
108
132
  worktree: { ...DEFAULT_CONFIG.worktree },
109
133
  docker: { ...DEFAULT_CONFIG.docker },
110
134
  seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
@@ -114,6 +138,7 @@ function defaults() {
114
138
  open: { ...DEFAULT_CONFIG.open },
115
139
  registry: DEFAULT_CONFIG.registry,
116
140
  branch: { ...DEFAULT_CONFIG.branch },
141
+ spec: { companionPaths: [] },
117
142
  baseBranch: DEFAULT_CONFIG.baseBranch,
118
143
  guards: { ...DEFAULT_CONFIG.guards },
119
144
  teardown: { ...DEFAULT_CONFIG.teardown },
@@ -257,6 +282,15 @@ function mergeConfig(base, parsed) {
257
282
  assign(base, parsed, 'registry', 'string')
258
283
  assign(base, parsed, 'baseBranch', 'string')
259
284
 
285
+ // Same treatment as `teardown.deleteRemoteBranch` below, for the same reason:
286
+ // an unrecognised value falls through to the default rather than erroring or
287
+ // being taken literally. The fallback direction matters — "worktree" is the
288
+ // conservative one, so a typo ("Checkout", "in-place") costs an extra terminal
289
+ // session, never a spec's work landing somewhere the author did not choose.
290
+ if (parsed.mode === 'worktree' || parsed.mode === 'checkout') {
291
+ base.mode = parsed.mode
292
+ }
293
+
260
294
  if (isObject(parsed.guards)) {
261
295
  assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
262
296
  assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
@@ -273,6 +307,10 @@ function mergeConfig(base, parsed) {
273
307
  }
274
308
  }
275
309
 
310
+ if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
311
+ base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
312
+ }
313
+
276
314
  if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
277
315
  base.live.migrations = normalizeFileList(parsed.live.migrations)
278
316
  }
@@ -16,6 +16,11 @@
16
16
  *
17
17
  * @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
18
18
  * @param {object} config normalised env config (unused today; kept for symmetry).
19
+ * In CHECKOUT mode there is no worktree: the branch is already checked out in the
20
+ * primary checkout, so landing is a rebase in place, a switch to base, and the
21
+ * fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
22
+ * returns is identical so callers need no second code path.
23
+ *
19
24
  * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
20
25
  * @returns {object} { blocked, noop, reason, commands, base, branch }
21
26
  */
@@ -43,4 +48,59 @@ function planIntegrate(spec, config, ctx) {
43
48
  }
44
49
  }
45
50
 
46
- module.exports = { planIntegrate }
51
+ /**
52
+ * Pure integrate planner for CHECKOUT mode.
53
+ *
54
+ * The branch lives in the primary checkout, so `git -C <worktree> rebase` — the
55
+ * worktree-mode plan — has nothing to address. Landing is three steps in one
56
+ * repo: rebase onto base, switch to base, fast-forward.
57
+ *
58
+ * The switch is what makes this safe to repeat. Leaving the checkout on the spec
59
+ * branch after landing would mean the next `spec-env up` refuses ("standing on
60
+ * another spec's branch") for a spec that is finished, and the operator would
61
+ * have to know to switch back by hand.
62
+ *
63
+ * ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch }
64
+ */
65
+ function planIntegrateCheckout(spec, config, ctx) {
66
+ const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
67
+ const branch = spec.branch
68
+ const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
69
+
70
+ // "Already landed" is answered FIRST, before any refusal. A landed spec needs
71
+ // no action wherever the checkout happens to be standing — and after a
72
+ // successful land it is standing on base, so asking "not on the branch" here
73
+ // would refuse the very spec this just finished landing. /spec-complete calls
74
+ // integrate again on exactly that state.
75
+ if (!aheadOfBase) {
76
+ return { ...result, noop: true }
77
+ }
78
+ if (dirty) {
79
+ return {
80
+ ...result,
81
+ blocked: true,
82
+ reason: 'the checkout has uncommitted changes — commit the completion first',
83
+ }
84
+ }
85
+ // There IS something to land, so where you are standing now matters: the
86
+ // branch is the checkout in this mode, and landing from elsewhere would
87
+ // rebase and fast-forward a branch the operator is not looking at.
88
+ if (onBranch === false) {
89
+ return {
90
+ ...result,
91
+ blocked: true,
92
+ reason: `the checkout is not on ${branch} — switch to it before landing`,
93
+ }
94
+ }
95
+
96
+ return {
97
+ ...result,
98
+ commands: [
99
+ `git -C ${checkoutPath} rebase ${base}`,
100
+ `git -C ${checkoutPath} switch ${base}`,
101
+ `git -C ${checkoutPath} merge --ff-only ${branch}`,
102
+ ],
103
+ }
104
+ }
105
+
106
+ module.exports = { planIntegrate, planIntegrateCheckout }
package/src/env/live.js CHANGED
@@ -134,7 +134,11 @@ function migrationsHit(files, patterns) {
134
134
  * ctx:
135
135
  * primary { onBase, branch, baseBranch } — the guard result for the primary checkout
136
136
  * primaryPath absolute path of the primary checkout (the checkout target)
137
+ * inFlight string|null — folder of the spec holding the primary, from
138
+ * its receipt; null when nothing is recorded (a hand-switched
139
+ * branch), which degrades the message to the branch name
137
140
  * clean boolean — primary checkout working tree is clean
141
+ * worktreeClean boolean — the SPEC WORKTREE's tree is clean (the rebase runs there)
138
142
  * worktreeExists boolean — the spec's worktree is on disk
139
143
  * base resolved base branch name (rebase target)
140
144
  * baseMainCommit primary HEAD before the switch (receipt / crash recovery)
@@ -166,9 +170,16 @@ function planTake(spec, config, ctx) {
166
170
  // (or you) already holds the live instance.
167
171
  if (!c.primary || !c.primary.onBase) {
168
172
  const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
173
+ // Name the SPEC, not just the branch, and name every way out. `/spec-start`
174
+ // relays this refusal verbatim as its gate, so whatever is missing here is
175
+ // missing from the operator's only explanation of why they are stuck —
176
+ // "release it with /spec-live main" alone reads as the sole option when
177
+ // completing or cancelling the held spec are usually the ones they want.
178
+ const held = c.inFlight ? `${c.inFlight} (branch ${on})` : on
169
179
  return block(
170
- `primary checkout is on ${on}, not ${base} — a spec already holds the live ` +
171
- 'instance; release it with `/spec-live main` first',
180
+ `primary checkout is on ${on}, not ${base} — ${held} already holds it. ` +
181
+ 'Free the workbench first: `/spec-complete` if it is finished, ' +
182
+ '`/spec-cancel` if it is not wanted, or `/spec-live main` to park it',
172
183
  )
173
184
  }
174
185
  // 2. Never switch a dirty tree — the checkout is reset back to base on release.
@@ -177,7 +188,20 @@ function planTake(spec, config, ctx) {
177
188
  }
178
189
  // 3. Need a worktree holding the branch to detach and hand over.
179
190
  if (!c.worktreeExists) {
180
- return block(`${spec.folder} has no worktree — run \`/spec-go ${spec.folder}\` first`)
191
+ return block(`${spec.folder} has no worktree — run \`/spec-start ${spec.folder}\` first`)
192
+ }
193
+ // 3b. The WORKTREE's own tree, not the primary checkout's. Check 2 above reads
194
+ // the checkout we switch INTO; the rebase runs in the worktree, and git
195
+ // refuses to rebase over uncommitted changes. Checking only the primary
196
+ // let a dirty worktree through to fail at the rebase, where the failure
197
+ // was then reported as a merge conflict — a wrong cause for a real
198
+ // problem, which is worse than no message. Validate the tree the
199
+ // operation actually touches.
200
+ if (c.worktreeClean === false) {
201
+ return block(
202
+ `${spec.folder}'s worktree has uncommitted changes — commit or stash them in ` +
203
+ `${spec.worktreePath} first (the rebase cannot run over them)`,
204
+ )
181
205
  }
182
206
  // 4. A hotfix is built on an old release tag; checking its branch out under the
183
207
  // running dev server risks schema/DB drift breaking the shared instance.
@@ -11,6 +11,7 @@
11
11
  * no live git/docker.
12
12
  */
13
13
 
14
+ const { classifyDirtyTree } = require('./classify.js')
14
15
  const { portOffset } = require('./registry.js')
15
16
  const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
17
  const { expandTokens } = require('./resolve.js')
@@ -71,6 +72,126 @@ function seedCommandFor(file, mode) {
71
72
  )
72
73
  }
73
74
 
75
+
76
+ // Cap a path list in a message: enough to recognise, not a wall of text.
77
+ function listPaths(paths) {
78
+ const shown = paths.slice(0, 5).join(', ')
79
+ return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown
80
+ }
81
+
82
+ /**
83
+ * Decide what an uncommitted tree means for a provisioning run: commit it, refuse
84
+ * it, or ignore it.
85
+ *
86
+ * `/spec-start` refuses a dirty tree so another spec's unfinished work is never
87
+ * moved without its author asking. But the commonest dirty tree of all is the
88
+ * spec you just wrote and are now starting, and refusing THAT costs a round trip
89
+ * through `/commit` on every start. So the question asked here is not "does this
90
+ * dirt look important?" — a judgement the gate rightly declines to make — but
91
+ * membership in an exactly-known set (see `classify.js`). One foreign path
92
+ * disqualifies the whole tree.
93
+ *
94
+ * WHAT WOULD FOOL THIS: `ctx.dirtyPaths` being absent. That is not "the tree is
95
+ * clean" — it is "nobody looked", which happens on every legacy caller and
96
+ * whenever git itself could not be read. Either way it is never permission to
97
+ * commit: with no classification there is no owned set, so no commit is planned.
98
+ *
99
+ * Whether it also REFUSES depends on `carriesChanges`, and the asymmetry is the
100
+ * two modes' actual risk, not an inconsistency:
101
+ *
102
+ * - checkout mode (`carriesChanges`) — `git switch -c` silently carries
103
+ * uncommitted work onto the new branch. Unable to see the tree means unable
104
+ * to rule that out, so it refuses, exactly as it did before this gate existed.
105
+ * - worktree mode — `git worktree add` carries nothing, and forks from a commit
106
+ * regardless. Refusing here would fire on a healthy repo whose git we merely
107
+ * could not read, which is a cost paid by someone who did nothing wrong. So
108
+ * it plans no commit and provisions as before.
109
+ *
110
+ * `specOnFork` is the other half, and a POSITIVE signal rather than an absence:
111
+ * the worktree forks from a specific commit, so a spec absent from it produces a
112
+ * branch missing the very spec it is for. A clean tree cannot detect that — the
113
+ * spec may be committed, just somewhere else. `null` means the caller could not
114
+ * tell (an unreadable git, or a hotfix, which forks from a tag predating its own
115
+ * spec), and routes to carrying on, never to refusing.
116
+ *
117
+ * ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
118
+ * @returns {{blocked: boolean, reason: string|null, commands: string[]}}
119
+ */
120
+ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
121
+ const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
122
+ const c = ctx || {}
123
+
124
+ if (!Array.isArray(c.dirtyPaths)) {
125
+ // Nobody looked: never commit, and refuse only where switching could carry
126
+ // work we cannot see (see the asymmetry above).
127
+ if (carriesChanges && c.clean === false) {
128
+ return {
129
+ blocked: true,
130
+ reason:
131
+ 'the primary checkout has uncommitted changes — commit or stash them first' +
132
+ (carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
133
+ commands: [],
134
+ owned: [],
135
+ verb: null,
136
+ }
137
+ }
138
+ return ok
139
+ }
140
+
141
+ const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
142
+
143
+ if (foreign.length) {
144
+ return {
145
+ blocked: true,
146
+ reason:
147
+ `the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
148
+ 'commit or stash them first' +
149
+ (carriesChanges ? ' (switching would carry them onto the new branch)' : '') +
150
+ `: ${listPaths(foreign)}`,
151
+ commands: [],
152
+ }
153
+ }
154
+
155
+ if (owned.length) {
156
+ // `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
157
+ // rather than inferred from the shape of the status output. The inference —
158
+ // "a bare directory entry means the folder is wholly untracked" — was wrong in
159
+ // the ordinary case of the FIRST spec in a bucket, where git reports the
160
+ // bucket as the untracked directory instead. The old inference stays as a
161
+ // fallback for callers that pass no fact; unknown yields `update`, which is
162
+ // never a false claim.
163
+ const isNew =
164
+ typeof c.specUntracked === 'boolean'
165
+ ? c.specUntracked
166
+ : owned.includes(`specs/${spec.bucket}/${spec.folder}`)
167
+ const verb = isNew ? 'add' : 'update'
168
+ return {
169
+ blocked: false,
170
+ reason: null,
171
+ owned,
172
+ verb,
173
+ commands: [
174
+ `git add ${owned.map((p) => `"${p}"`).join(' ')}`,
175
+ `git commit -m "chore(spec): ${verb} ${spec.folder}"`,
176
+ ],
177
+ }
178
+ }
179
+
180
+ // Tree is clean. The spec must already be in the base branch's tree, or the
181
+ // worktree forks without it.
182
+ if (c.specOnFork === false) {
183
+ return {
184
+ blocked: true,
185
+ reason:
186
+ `${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
187
+ (c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
188
+ ' — the worktree would fork without the spec it is for',
189
+ commands: [],
190
+ }
191
+ }
192
+ return ok
193
+ }
194
+
74
195
  /**
75
196
  * Plan a provisioning run.
76
197
  *
@@ -83,7 +204,7 @@ function seedCommandFor(file, mode) {
83
204
  * envContents, openCommand, commands, seedCommands,
84
205
  * setupCommands, attached }
85
206
  */
86
- function planUp(spec, alloc, config) {
207
+ function planUp(spec, alloc, config, ctx) {
87
208
  const { slot, attached } = alloc
88
209
 
89
210
  // Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
@@ -143,7 +264,18 @@ function planUp(spec, alloc, config) {
143
264
  commands.push(`docker compose --project-name ${spec.projectName} up -d`)
144
265
  }
145
266
 
267
+ // The tree gate. A worktree forks from the base branch's tree, so an
268
+ // uncommitted spec would produce a branch missing the spec it is for — this is
269
+ // where that gets committed, or refused. Worktree mode had NO clean gate before
270
+ // this: `git worktree add` does not carry uncommitted changes anywhere, so the
271
+ // failure surfaced three steps later as a live-overlay refusal naming the wrong
272
+ // stage. An absent `ctx` means no caller looked, and changes nothing.
273
+ const gate = planSpecCommit(spec, ctx, config)
274
+
146
275
  return {
276
+ blocked: gate.blocked,
277
+ reason: gate.reason,
278
+ specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
147
279
  worktreePath: spec.worktreePath,
148
280
  branch: spec.branch,
149
281
  projectName: spec.projectName,
@@ -151,11 +283,70 @@ function planUp(spec, alloc, config) {
151
283
  portOffset: offset,
152
284
  envContents,
153
285
  openCommand,
154
- commands,
155
- seedCommands,
156
- setupCommands,
286
+ commands: gate.blocked ? [] : [...gate.commands, ...commands],
287
+ seedCommands: gate.blocked ? [] : seedCommands,
288
+ setupCommands: gate.blocked ? [] : setupCommands,
157
289
  attached,
158
290
  }
159
291
  }
160
292
 
161
- module.exports = { planUp, seedCommandFor, worktreeCd }
293
+ /**
294
+ * Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
295
+ * checkout and there is no worktree at all.
296
+ *
297
+ * Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
298
+ *
299
+ * ctx: { current, base, onBase, clean, branchExists }
300
+ *
301
+ * What it refuses, and why each is a refusal rather than a warning:
302
+ * - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
303
+ * new branch. That is silent and it is the operator's work, so it is theirs
304
+ * to place, not ours.
305
+ * - standing on another branch, because switching away from it is a decision
306
+ * about someone else's unfinished spec. Being on THIS spec's branch is not a
307
+ * refusal — it is the re-run, and the answer is "already attached".
308
+ *
309
+ * There is no bootstrap and no opener: the primary checkout already has its
310
+ * dependencies, and no new session is being opened.
311
+ */
312
+ function planCheckoutUp(spec, ctx, config) {
313
+ const base = ctx.base || (config && config.baseBranch) || 'main'
314
+ const result = {
315
+ mode: 'checkout',
316
+ blocked: false,
317
+ reason: null,
318
+ attached: false,
319
+ branch: spec.branch,
320
+ checkoutPath: ctx.checkoutPath || null,
321
+ commands: [],
322
+ }
323
+ const block = (reason) => ({ ...result, blocked: true, reason })
324
+
325
+ // Order matters: report "already attached" before anything else, so a re-run
326
+ // on the spec's own branch is never refused for a dirty tree it legitimately
327
+ // has — you are mid-phase, with the phase's own edits in progress.
328
+ if (ctx.current && ctx.current === spec.branch) {
329
+ return { ...result, attached: true }
330
+ }
331
+ // Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
332
+ // work onto the new branch, so the refusal keeps saying so.
333
+ const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
334
+ if (gate.blocked) return block(gate.reason)
335
+ if (!ctx.onBase) {
336
+ return block(
337
+ `the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
338
+ 'finish or park that branch first; checkout mode holds one spec at a time',
339
+ )
340
+ }
341
+
342
+ if (gate.owned && gate.owned.length) {
343
+ result.specCommit = { paths: gate.owned, verb: gate.verb }
344
+ }
345
+ result.commands.push(...gate.commands)
346
+ result.commands.push(
347
+ ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
348
+ )
349
+ return result
350
+ }
351
+
352
+ module.exports = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
@@ -267,6 +267,7 @@ module.exports = {
267
267
  repoInfo,
268
268
  expandTokens,
269
269
  findSpecFolder,
270
+ readFrontmatterField,
270
271
  readStackField,
271
272
  readBaseVersionField,
272
273
  }
@@ -105,7 +105,7 @@ function planDown(spec, config, flags, ctx) {
105
105
  // different question from ours: it also declines a branch that is ahead of its
106
106
  // upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
107
107
  // even though it is merged to HEAD`. That fires on the ordinary spec flow —
108
- // `/spec-go` pushes the branch when it provisions, and the phase commits after
108
+ // `/spec-start` pushes the branch when it provisions, and the phase commits after
109
109
  // it are landed locally rather than pushed — so teardown meets a branch whose
110
110
  // every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
111
111
  // of base) already establishes what we actually care about, and establishes it
@@ -122,7 +122,7 @@ function planDown(spec, config, flags, ctx) {
122
122
 
123
123
  // --- delete the branch on the remote (planned, never run here) ---
124
124
  //
125
- // `/spec-go` pushes the branch at provision time, so a completed spec otherwise
125
+ // `/spec-start` pushes the branch at provision time, so a completed spec otherwise
126
126
  // leaves a merged branch on the remote forever. Cleaning that up is the goal;
127
127
  // doing it safely is the constraint.
128
128
  //
@@ -185,4 +185,42 @@ function blocked(reason) {
185
185
  }
186
186
  }
187
187
 
188
- module.exports = { planDown }
188
+ /**
189
+ * Pure teardown planner for CHECKOUT mode.
190
+ *
191
+ * There is no worktree to remove, no slot and no volumes — the only thing a
192
+ * finished spec leaves behind is its branch, and the checkout standing on it.
193
+ *
194
+ * Order is load-bearing: git refuses to delete the branch you are on, so the
195
+ * switch to base must come first. It is also what returns the checkout to a
196
+ * state the next `spec-env up` will accept.
197
+ *
198
+ * The same "are these commits recoverable?" question decides `-d` vs `-D`, and
199
+ * it is asked exactly as worktree-mode teardown asks it — a landed branch is
200
+ * safe to force-delete because its commits are on base (or under a tag); an
201
+ * unlanded one is not, and is refused rather than quietly dropped.
202
+ *
203
+ * ctx: { dirty, landed, onBranch, base, checkoutPath }
204
+ */
205
+ function planDownCheckout(spec, config, flags, ctx) {
206
+ const { dirty, landed, onBranch, base, checkoutPath } = ctx || {}
207
+ const force = Boolean(flags && flags.force)
208
+ const result = { mode: 'checkout', blocked: false, reason: null, commands: [], branch: spec.branch }
209
+ const block = (reason) => ({ ...result, blocked: true, reason })
210
+
211
+ if (!force) {
212
+ if (config.guards.refuseTeardownIfDirty && dirty) {
213
+ return block('the checkout has uncommitted changes')
214
+ }
215
+ if (!landed) {
216
+ return block(`${spec.branch} is not merged into ${base} — landing it first is what makes the delete safe`)
217
+ }
218
+ }
219
+
220
+ const commands = []
221
+ if (onBranch !== false) commands.push(`git -C ${checkoutPath} switch ${base}`)
222
+ commands.push(`git -C ${checkoutPath} branch ${landed ? '-D' : '-d'} ${spec.branch}`)
223
+ return { ...result, commands }
224
+ }
225
+
226
+ module.exports = { planDown, planDownCheckout }