@skitterbyte/skitterspec-linear 10.8.0 → 11.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.
package/src/cli.js CHANGED
@@ -37,10 +37,10 @@ const {
37
37
  planAbort,
38
38
  } = require('./env/live.js')
39
39
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
40
- const { planUp } = require('./env/provision.js')
41
- const { planDown } = require('./env/teardown.js')
40
+ const { planUp, planCheckoutUp } = require('./env/provision.js')
41
+ const { planDown, planDownCheckout } = require('./env/teardown.js')
42
42
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
43
- const { planIntegrate } = require('./env/integrate.js')
43
+ const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
44
44
  const { planHotfixLand } = require('./env/hotfix.js')
45
45
  const { planDev } = require('./env/dev.js')
46
46
  const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
@@ -233,9 +233,63 @@ function specEnvStatus(dir, config) {
233
233
  // the /spec-env skill executes (git worktree add, docker compose up, .env,
234
234
  // opener). This creates no worktree and starts no stack — the caller runs the
235
235
  // printed commands. Keep the output's verb honest about that.
236
+ // `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
237
+ // planner, and prints the plan or the refusal.
238
+ function specEnvUpCheckout(dir, config, spec) {
239
+ const git = gitReader(dir)
240
+ const primary = assertPrimaryOnMain(config, git)
241
+ const base = resolveBaseBranch(config, git)
242
+ const status = git(['status', '--porcelain'])
243
+
244
+ const plan = planCheckoutUp(
245
+ spec,
246
+ {
247
+ current: primary.branch,
248
+ base,
249
+ onBase: primary.onBase,
250
+ // A null status means git could not be read at all. Treated as NOT clean:
251
+ // the harmless outcome of being wrong is a refusal the operator can act
252
+ // on, and the harmful one is carrying their work onto a new branch.
253
+ clean: status !== null && status.length === 0,
254
+ branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
255
+ checkoutPath: dir,
256
+ },
257
+ config,
258
+ )
259
+
260
+ if (plan.blocked) {
261
+ process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
262
+ return
263
+ }
264
+
265
+ const out = [
266
+ `spec-env up: ${spec.folder} ` +
267
+ (plan.attached ? '(already on this branch — nothing to do)' : '(plan — nothing created yet)'),
268
+ '',
269
+ ` mode: checkout (branch built in the primary checkout)`,
270
+ ` checkout: ${plan.checkoutPath}`,
271
+ ` branch: ${plan.branch}`,
272
+ ' stack: checkout-only (no worktree, no docker, no port block)',
273
+ ]
274
+ if (plan.commands.length) {
275
+ out.push('')
276
+ out.push(' to provision, run:')
277
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
278
+ }
279
+ process.stdout.write(out.join('\n') + '\n')
280
+ }
281
+
236
282
  function specEnvUp(dir, config, specArg) {
237
283
  const spec = resolveSpecWithWorktree(dir, config, specArg)
238
284
 
285
+ // Checkout mode: the branch is built in the primary checkout, so none of the
286
+ // worktree machinery below applies — no slot, no trust entry, no bootstrap and
287
+ // no opener. Handled first precisely so none of that runs by accident.
288
+ if (config.mode === 'checkout') {
289
+ specEnvUpCheckout(dir, config, spec)
290
+ return
291
+ }
292
+
239
293
  // Live-safe: if this spec is already live on the primary checkout (its branch was
240
294
  // branch-switched in by `live take`), a `git worktree add` would fail — the branch
241
295
  // is checked out there. Point the operator at the primary checkout rather than
@@ -251,7 +305,7 @@ function specEnvUp(dir, config, specArg) {
251
305
 
252
306
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
253
307
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
254
- // every provision for teammates who only cloned and ran /spec-go.
308
+ // every provision for teammates who only cloned and ran /spec-start.
255
309
  const worktreeRootAbs = path.dirname(spec.worktreePath)
256
310
  const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
257
311
 
@@ -295,6 +349,10 @@ function specEnvUp(dir, config, specArg) {
295
349
  } else {
296
350
  out.push(' stack: worktree-only (no docker, no port block)')
297
351
  }
352
+ // The loader falls back silently on an unrecognised `mode`, so this line is
353
+ // the operator's only evidence of which mode actually resolved — print it
354
+ // whenever it was set explicitly, right or wrong.
355
+ out.push(' mode: worktree (each spec gets its own checkout)')
298
356
  if (trust.reason === 'malformed') {
299
357
  out.push(
300
358
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -429,12 +487,45 @@ function compactTimestamp() {
429
487
  // when the spec was never provisioned / already torn down. Deliberately does NOT
430
488
  // touch the trusted worktree root in .claude/settings.local.json — that entry is
431
489
  // the shared parent of every spec's worktree and harmless when empty; removing it
432
- // would just re-prompt on the next /spec-go (see spec: isolation-trusts-worktree-dir).
490
+ // would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
433
491
  function specEnvDown(dir, config, specArg, flags) {
434
492
  const spec = resolveSpecWithWorktree(dir, config, specArg)
435
493
 
436
494
  // A worktree-only spec never held a slot but its worktree still needs removing,
437
495
  // so "nothing to do" means neither a slot nor a worktree exists.
496
+ // Checkout mode first: there is no worktree and no registry slot by design, so
497
+ // the "not provisioned" guard below would report a live spec as absent.
498
+ if (config.mode === 'checkout') {
499
+ const dgit = gitReader(dir)
500
+ const dbase = resolveBaseBranch(config, dgit)
501
+ if (dgit(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) === null) {
502
+ process.stdout.write(`spec-env down: ${spec.folder} has no branch — nothing to do.\n`)
503
+ return
504
+ }
505
+ const dst = dgit(['status', '--porcelain'])
506
+ const contains = dgit(['branch', '--contains', spec.branch, '--list', dbase])
507
+ const dplan = planDownCheckout(spec, config, flags, {
508
+ dirty: dst === null || dst.length > 0,
509
+ landed: Boolean(contains && contains.trim()),
510
+ onBranch: dgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
511
+ base: dbase,
512
+ checkoutPath: dir,
513
+ })
514
+ if (dplan.blocked) {
515
+ process.stdout.write(
516
+ `spec-env down: blocked — ${dplan.reason}.\n` +
517
+ 'Re-run with --force to tear down anyway (deletes the branch).\n',
518
+ )
519
+ return
520
+ }
521
+ const dout = [`spec-env down: ${spec.folder}`, '',
522
+ ' mode: checkout (no worktree, no slot, no volumes)',
523
+ ` branch: ${dplan.branch}`, '', ' run these:']
524
+ for (const cmd of dplan.commands) dout.push(` ${cmd}`)
525
+ process.stdout.write(dout.join('\n') + '\n')
526
+ return
527
+ }
528
+
438
529
  const registry = readRegistry(dir, config)
439
530
  const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
440
531
  if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
@@ -566,7 +657,7 @@ function liveWorktreePaths(dir) {
566
657
  * bucket disappears the moment it empties.
567
658
  *
568
659
  * Three outcomes, never two: resolved → that spec; several candidates and no cwd
569
- * hint → throw, listing them; none → throw, pointing at /spec-go. *Cannot tell*
660
+ * hint → throw, listing them; none → throw, pointing at /spec-start. *Cannot tell*
570
661
  * never becomes a guess.
571
662
  *
572
663
  * BLIND SPOT: a spec taken live with `/spec-live` has had its branch moved into
@@ -603,7 +694,7 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
603
694
  if (provisioned.length === 0) {
604
695
  throw new Error(
605
696
  'no spec given, and no spec has a worktree — name one explicitly, or run ' +
606
- '/spec-go to provision it.',
697
+ '/spec-start to provision it.',
607
698
  )
608
699
  }
609
700
  throw new Error(
@@ -764,6 +855,39 @@ function specEnvIntegrate(dir, config, specArg) {
764
855
  const spec = resolveSpecWithWorktree(dir, config, specArg)
765
856
  const base = resolveBaseBranch(config, gitReader(dir))
766
857
 
858
+ // Checkout mode short-circuits everything below. The live handling in
859
+ // particular reads "primary is on the spec's branch" as a live session — true
860
+ // in worktree mode, and simply where the branch LIVES in checkout mode, so it
861
+ // would refuse to land a spec sitting exactly where it belongs.
862
+ if (config.mode === 'checkout') {
863
+ const cgit = gitReader(dir)
864
+ const cst = cgit(['status', '--porcelain'])
865
+ const cahead = cgit(['rev-list', '--count', `${base}..${spec.branch}`])
866
+ const cplan = planIntegrateCheckout(spec, config, {
867
+ dirty: cst === null || cst.length > 0,
868
+ base,
869
+ aheadOfBase: cahead !== null && Number(cahead) > 0,
870
+ checkoutPath: dir,
871
+ onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
872
+ })
873
+ if (cplan.blocked) {
874
+ process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
875
+ return
876
+ }
877
+ if (cplan.noop) {
878
+ process.stdout.write(
879
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
880
+ )
881
+ return
882
+ }
883
+ const cout = [`spec-env integrate: ${spec.folder}`, '', ' mode: checkout',
884
+ ` base: ${base}`, ` branch: ${cplan.branch}`, '',
885
+ ' run these (abort the rebase on conflict):']
886
+ for (const cmd of cplan.commands) cout.push(` ${cmd}`)
887
+ process.stdout.write(cout.join('\n') + '\n')
888
+ return
889
+ }
890
+
767
891
  // Live-aware: if this spec is live on the primary checkout (branch-switched by
768
892
  // `live take`), end the live session first — release back to base, re-isolate the
769
893
  // branch, clear the receipt — so the normal rebase→ff plan below applies
@@ -798,7 +922,7 @@ function specEnvIntegrate(dir, config, specArg) {
798
922
  const liveWtGit = gitReader(spec.worktreePath)
799
923
  if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
800
924
  // Detached worktree HEAD: any commits ahead of the branch ref (e.g. made by a
801
- // non-live-aware /spec-go) would be abandoned by the re-isolate `switch` below.
925
+ // a build that committed in the worktree) would be abandoned by the re-isolate `switch` below.
802
926
  const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
803
927
  const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
804
928
  if (stranded !== null && Number(stranded) > 0) {
@@ -1050,6 +1174,16 @@ function proxyProcFor(config, routesFileAbs) {
1050
1174
  // bundled proxy pointing at that spec's warm dev servers. `connect main` stops
1051
1175
  // the proxy so the primary checkout owns the canonical ports again.
1052
1176
  async function specEnvConnect(dir, config, specArg) {
1177
+ // Both verbs exist to route around the work living somewhere other than the
1178
+ // checkout you are in — a proxy to a second stack, or a temporary branch swap.
1179
+ // Checkout mode closes that gap permanently, so there is nothing to route.
1180
+ if (config.mode === 'checkout') {
1181
+ process.stdout.write(
1182
+ 'spec-env connect: not applicable in checkout mode — the spec is built in the ' +
1183
+ 'primary checkout, so your dev server already serves it on the canonical ports.\\n',
1184
+ )
1185
+ return
1186
+ }
1053
1187
  const sdir = stateDirLabel(config)
1054
1188
  const abs = (rel) => path.resolve(dir, rel)
1055
1189
  const routesFile = `${sdir}/proxy.json`
@@ -1151,6 +1285,17 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
1151
1285
  // receipt is advisory metadata. `status` is read-only; `take` performs the switch
1152
1286
  // (release/abort land in a later phase).
1153
1287
  async function specEnvLive(dir, config, positional) {
1288
+ // Both verbs exist to route around the work living somewhere other than the
1289
+ // checkout you are in — a proxy to a second stack, or a temporary branch swap.
1290
+ // Checkout mode closes that gap permanently, so there is nothing to route.
1291
+ if (config.mode === 'checkout') {
1292
+ process.stdout.write(
1293
+ 'spec-env live: not applicable in checkout mode — the spec branch is already ' +
1294
+ 'checked out here. `mode: checkout` is the permanent form of what live overlay ' +
1295
+ 'does temporarily.\\n',
1296
+ )
1297
+ return
1298
+ }
1154
1299
  const { action, specArg } = liveGrammar(dir, config, positional)
1155
1300
  switch (action) {
1156
1301
  case 'status':
@@ -1210,6 +1355,10 @@ async function specEnvLiveTake(dir, config, specArg) {
1210
1355
  const status = primaryGit(['status', '--porcelain'])
1211
1356
  const clean = status !== null && status.length === 0
1212
1357
  const worktreeExists = fs.existsSync(spec.worktreePath)
1358
+ // The tree the rebase actually runs in. Unreadable → treated as dirty: being
1359
+ // wrong that way costs a message, the other way moves work we could not see.
1360
+ const wtStatus = worktreeExists ? gitReader(spec.worktreePath)(['status', '--porcelain']) : ''
1361
+ const worktreeClean = wtStatus !== null && wtStatus.length === 0
1213
1362
  const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
1214
1363
 
1215
1364
  // Diff base...branch to spot migration / dependency changes (best-effort).
@@ -1229,7 +1378,9 @@ async function specEnvLiveTake(dir, config, specArg) {
1229
1378
  const plan = planTake(spec, config, {
1230
1379
  primary,
1231
1380
  primaryPath: dir,
1381
+ inFlight: (readReceipt(dir, config) || {}).spec || null,
1232
1382
  clean,
1383
+ worktreeClean,
1233
1384
  worktreeExists,
1234
1385
  base,
1235
1386
  baseMainCommit,
@@ -1249,10 +1400,19 @@ async function specEnvLiveTake(dir, config, specArg) {
1249
1400
  // Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
1250
1401
  const reb = runGit(spec.worktreePath, ['rebase', base])
1251
1402
  if (!reb.ok) {
1252
- runGit(spec.worktreePath, ['rebase', '--abort'])
1403
+ // A rebase fails two ways and they need different answers. It can REFUSE TO
1404
+ // START (unstaged changes, a missing base) — nothing to abort, nothing to
1405
+ // resolve — or start and CONFLICT. Calling both "hit conflicts" sent people
1406
+ // hunting a conflict that did not exist, and `--abort` on a rebase that
1407
+ // never began discarded git's own explanation of what was actually wrong.
1408
+ const started = runGit(spec.worktreePath, ['rebase', '--show-current-patch']).ok
1409
+ if (started) runGit(spec.worktreePath, ['rebase', '--abort'])
1253
1410
  process.stdout.write(
1254
- `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
1255
- `resolve them in ${spec.worktreePath}, then retry.\n`,
1411
+ started
1412
+ ? `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
1413
+ `resolve them in ${spec.worktreePath}, then retry.\n`
1414
+ : `spec-env live take: rebase of ${spec.branch} onto ${base} could not start — ` +
1415
+ `git said:\n ${reb.err.split('\n').join('\n ')}\n`,
1256
1416
  )
1257
1417
  return
1258
1418
  }
@@ -1400,7 +1560,7 @@ async function specEnvLiveAbort(dir, config) {
1400
1560
  function specEnvLiveStatus(dir, config, specArg) {
1401
1561
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
1402
1562
 
1403
- // Per-spec query (`live status <spec>`): a clear yes/no verdict the /spec-go
1563
+ // Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
1404
1564
  // skill branches on to decide whether to skip worktree provisioning and work in
1405
1565
  // the primary checkout. The stable `live: yes|no` line is the machine seam.
1406
1566
  if (specArg) {
@@ -1423,9 +1583,23 @@ function specEnvLiveStatus(dir, config, specArg) {
1423
1583
  const state = onBase
1424
1584
  ? 'on base — free'
1425
1585
  : `feature in control — not on ${baseBranch}`
1586
+
1587
+ // `in-flight:` is a MACHINE SEAM, like the per-spec `live:` line above, and
1588
+ // `/spec-next` reads it to decide which spec it is allowed to build. It answers
1589
+ // in three states rather than two, because "cannot tell" is real here: a branch
1590
+ // switched by hand carries no receipt, so the spec is unknown even though the
1591
+ // checkout is plainly busy. Reporting that as `none` would invite building the
1592
+ // wrong spec; reporting the branch says what is true and lets the caller stop.
1593
+ const inFlight = onBase
1594
+ ? 'none — the workbench is free'
1595
+ : receipt && receipt.spec
1596
+ ? `${receipt.spec} (branch ${branch || '(detached)'})`
1597
+ : `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
1598
+
1426
1599
  process.stdout.write(
1427
1600
  'spec-env live:\n' +
1428
1601
  ` primary: ${branch || '(detached)'} (${state})\n` +
1602
+ ` in-flight: ${inFlight}\n` +
1429
1603
  ` receipt: ${summarizeReceipt(receipt)}\n`,
1430
1604
  )
1431
1605
  }
@@ -1560,11 +1734,21 @@ async function run(argv) {
1560
1734
  // Fresh repo (or create-missing): isolation defaults OFF; a flag or an
1561
1735
  // interactive "yes" opts in. Only prompt for isolation on a fresh repo.
1562
1736
  let isolation = opts.isolation === true
1737
+ let workspaceMode = 'worktree'
1563
1738
  if (interactive && !isExistingSetup(dir)) {
1564
1739
  const { promptSetup } = require('./prompts.js')
1565
- isolation = (await promptSetup({ isolationSeed: isolation })).isolation
1740
+ const answers = await promptSetup({ isolationSeed: isolation })
1741
+ isolation = answers.isolation
1742
+ workspaceMode = answers.mode
1566
1743
  }
1567
- await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
1744
+ await init({
1745
+ dir,
1746
+ force: opts.force,
1747
+ claudeMd: opts.claudeMd,
1748
+ mode: 'init',
1749
+ isolation,
1750
+ workspaceMode,
1751
+ })
1568
1752
  break
1569
1753
  }
1570
1754
  case 'update':
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 },
@@ -44,6 +47,16 @@ const { join } = require('node:path')
44
47
  const CONFIG_FILE = join('specs', '.core', 'env.config.json')
45
48
 
46
49
  const DEFAULT_CONFIG = Object.freeze({
50
+ // Where a spec's branch is built. "worktree" gives every spec its own checkout
51
+ // — parallel specs, `main` left free — at the cost of a terminal session per
52
+ // spec. "checkout" builds the branch in the primary checkout instead: one spec
53
+ // at a time, in the terminal you are already sitting in.
54
+ //
55
+ // Defaults to "worktree" so no installed repo changes behaviour on upgrade.
56
+ // Never inferred from whether `dev`/`docker` are configured: a repo with no
57
+ // dev servers may still want parallel specs, and absence of configuration is
58
+ // not evidence of intent (see .claude/rules/negative-checks.md).
59
+ mode: 'worktree',
47
60
  worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
48
61
  docker: Object.freeze({
49
62
  enabled: true,
@@ -81,7 +94,7 @@ const DEFAULT_CONFIG = Object.freeze({
81
94
  baseBranch: '',
82
95
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
83
96
  // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
84
- // `spec-env down` does about the branch `/spec-go` pushed: "prompt" (default)
97
+ // `spec-env down` does about the branch `/spec-start` pushed: "prompt" (default)
85
98
  // plans the delete in its own confirm-first section for the skill to ask about,
86
99
  // "never" omits it, "always" folds it into the run-blind command list. Only ever
87
100
  // planned for a LANDED branch — see teardown.js.
@@ -105,6 +118,7 @@ function isObject(value) {
105
118
  // A fresh, deeply-mutable copy of the defaults to merge onto.
106
119
  function defaults() {
107
120
  return {
121
+ mode: DEFAULT_CONFIG.mode,
108
122
  worktree: { ...DEFAULT_CONFIG.worktree },
109
123
  docker: { ...DEFAULT_CONFIG.docker },
110
124
  seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
@@ -257,6 +271,15 @@ function mergeConfig(base, parsed) {
257
271
  assign(base, parsed, 'registry', 'string')
258
272
  assign(base, parsed, 'baseBranch', 'string')
259
273
 
274
+ // Same treatment as `teardown.deleteRemoteBranch` below, for the same reason:
275
+ // an unrecognised value falls through to the default rather than erroring or
276
+ // being taken literally. The fallback direction matters — "worktree" is the
277
+ // conservative one, so a typo ("Checkout", "in-place") costs an extra terminal
278
+ // session, never a spec's work landing somewhere the author did not choose.
279
+ if (parsed.mode === 'worktree' || parsed.mode === 'checkout') {
280
+ base.mode = parsed.mode
281
+ }
282
+
260
283
  if (isObject(parsed.guards)) {
261
284
  assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
262
285
  assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
@@ -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.
@@ -158,4 +158,61 @@ function planUp(spec, alloc, config) {
158
158
  }
159
159
  }
160
160
 
161
- module.exports = { planUp, seedCommandFor, worktreeCd }
161
+ /**
162
+ * Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
163
+ * checkout and there is no worktree at all.
164
+ *
165
+ * Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
166
+ *
167
+ * ctx: { current, base, onBase, clean, branchExists }
168
+ *
169
+ * What it refuses, and why each is a refusal rather than a warning:
170
+ * - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
171
+ * new branch. That is silent and it is the operator's work, so it is theirs
172
+ * to place, not ours.
173
+ * - standing on another branch, because switching away from it is a decision
174
+ * about someone else's unfinished spec. Being on THIS spec's branch is not a
175
+ * refusal — it is the re-run, and the answer is "already attached".
176
+ *
177
+ * There is no bootstrap and no opener: the primary checkout already has its
178
+ * dependencies, and no new session is being opened.
179
+ */
180
+ function planCheckoutUp(spec, ctx, config) {
181
+ const base = ctx.base || (config && config.baseBranch) || 'main'
182
+ const result = {
183
+ mode: 'checkout',
184
+ blocked: false,
185
+ reason: null,
186
+ attached: false,
187
+ branch: spec.branch,
188
+ checkoutPath: ctx.checkoutPath || null,
189
+ commands: [],
190
+ }
191
+ const block = (reason) => ({ ...result, blocked: true, reason })
192
+
193
+ // Order matters: report "already attached" before anything else, so a re-run
194
+ // on the spec's own branch is never refused for a dirty tree it legitimately
195
+ // has — you are mid-phase, with the phase's own edits in progress.
196
+ if (ctx.current && ctx.current === spec.branch) {
197
+ return { ...result, attached: true }
198
+ }
199
+ if (!ctx.clean) {
200
+ return block(
201
+ 'the primary checkout has uncommitted changes — commit or stash them first ' +
202
+ '(switching would carry them onto the new branch)',
203
+ )
204
+ }
205
+ if (!ctx.onBase) {
206
+ return block(
207
+ `the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
208
+ 'finish or park that branch first; checkout mode holds one spec at a time',
209
+ )
210
+ }
211
+
212
+ result.commands.push(
213
+ ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
214
+ )
215
+ return result
216
+ }
217
+
218
+ module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
@@ -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 }