@skitterbyte/skitterspec 16.8.0 → 17.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,25 +1285,64 @@ 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) {
1154
- const action = positional[0] || 'status'
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
+ }
1299
+ const { action, specArg } = liveGrammar(dir, config, positional)
1155
1300
  switch (action) {
1156
1301
  case 'status':
1157
- specEnvLiveStatus(dir, config, positional[1])
1302
+ specEnvLiveStatus(dir, config, specArg)
1158
1303
  break
1159
1304
  case 'take':
1160
- await specEnvLiveTake(dir, config, positional[1])
1305
+ await specEnvLiveTake(dir, config, specArg)
1161
1306
  break
1162
1307
  case 'release':
1163
- await specEnvLiveRelease(dir, config, positional[1])
1308
+ await specEnvLiveRelease(dir, config, specArg)
1164
1309
  break
1165
1310
  case 'abort':
1166
1311
  await specEnvLiveAbort(dir, config)
1167
1312
  break
1168
1313
  default:
1169
- process.stdout.write('Usage: skitterspec spec-env live <take|release|abort|status> [spec]\n')
1314
+ process.stdout.write(
1315
+ 'Usage: skitterspec spec-env live <spec>|<base branch>|<take|release|abort|status> [spec]\n',
1316
+ )
1170
1317
  }
1171
1318
  }
1172
1319
 
1320
+ const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
1321
+
1322
+ // The two front doors every doc names — `/spec-live <spec>` and `/spec-live main`
1323
+ // — translated to verbs. They live here rather than in the command because
1324
+ // `.claude/commands/spec-live.md` relays `$ARGUMENTS` untranslated (it is a
1325
+ // pre-executed script, with no model turn to rewrite them); the skill this
1326
+ // replaced did the translation itself, which is how these forms came to be
1327
+ // documented but unimplemented. `connect` needs no equivalent — its argument was
1328
+ // always spec-shaped (`specArg || 'main'`).
1329
+ //
1330
+ // VERB PRECEDENCE IS DELIBERATE, and so is the order below: the four verbs and
1331
+ // the base branch are matched BEFORE the spec-name fallback, so a spec folder
1332
+ // that happens to be called `status` cannot silently branch-switch the primary
1333
+ // checkout. Such a spec is still reachable — as `live take status`. The literal
1334
+ // `main` is honoured even where the base branch is named something else,
1335
+ // matching `connect main`, so the muscle memory works in either repo.
1336
+ function liveGrammar(dir, config, positional) {
1337
+ const [first, second] = positional
1338
+ if (!first) return { action: 'status', specArg: undefined }
1339
+ if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
1340
+ if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
1341
+ return { action: 'release', specArg: undefined }
1342
+ }
1343
+ return { action: 'take', specArg: first }
1344
+ }
1345
+
1173
1346
  // Take the running instance: rebase the spec's branch onto base, free it from its
1174
1347
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1175
1348
  async function specEnvLiveTake(dir, config, specArg) {
@@ -1182,6 +1355,10 @@ async function specEnvLiveTake(dir, config, specArg) {
1182
1355
  const status = primaryGit(['status', '--porcelain'])
1183
1356
  const clean = status !== null && status.length === 0
1184
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
1185
1362
  const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
1186
1363
 
1187
1364
  // Diff base...branch to spot migration / dependency changes (best-effort).
@@ -1201,7 +1378,9 @@ async function specEnvLiveTake(dir, config, specArg) {
1201
1378
  const plan = planTake(spec, config, {
1202
1379
  primary,
1203
1380
  primaryPath: dir,
1381
+ inFlight: (readReceipt(dir, config) || {}).spec || null,
1204
1382
  clean,
1383
+ worktreeClean,
1205
1384
  worktreeExists,
1206
1385
  base,
1207
1386
  baseMainCommit,
@@ -1221,10 +1400,19 @@ async function specEnvLiveTake(dir, config, specArg) {
1221
1400
  // Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
1222
1401
  const reb = runGit(spec.worktreePath, ['rebase', base])
1223
1402
  if (!reb.ok) {
1224
- 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'])
1225
1410
  process.stdout.write(
1226
- `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
1227
- `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`,
1228
1416
  )
1229
1417
  return
1230
1418
  }
@@ -1372,7 +1560,7 @@ async function specEnvLiveAbort(dir, config) {
1372
1560
  function specEnvLiveStatus(dir, config, specArg) {
1373
1561
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
1374
1562
 
1375
- // 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
1376
1564
  // skill branches on to decide whether to skip worktree provisioning and work in
1377
1565
  // the primary checkout. The stable `live: yes|no` line is the machine seam.
1378
1566
  if (specArg) {
@@ -1395,9 +1583,23 @@ function specEnvLiveStatus(dir, config, specArg) {
1395
1583
  const state = onBase
1396
1584
  ? 'on base — free'
1397
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
+
1398
1599
  process.stdout.write(
1399
1600
  'spec-env live:\n' +
1400
1601
  ` primary: ${branch || '(detached)'} (${state})\n` +
1602
+ ` in-flight: ${inFlight}\n` +
1401
1603
  ` receipt: ${summarizeReceipt(receipt)}\n`,
1402
1604
  )
1403
1605
  }
@@ -1466,7 +1668,9 @@ async function specEnv(rest) {
1466
1668
  ' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
1467
1669
  ' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
1468
1670
  ' NOTE connect and live status keep their own meaning for a missing spec:\n' +
1469
- ' connect disconnects (= main), live status reports on the whole repo.\n',
1671
+ ' connect disconnects (= main), live status reports on the whole repo.\n' +
1672
+ ' connect and live also take a bare spec name: `live <spec>` takes the\n' +
1673
+ ' instance, `live main` (or your base branch) hands it back.\n',
1470
1674
  )
1471
1675
  }
1472
1676
  }
@@ -1530,11 +1734,21 @@ async function run(argv) {
1530
1734
  // Fresh repo (or create-missing): isolation defaults OFF; a flag or an
1531
1735
  // interactive "yes" opts in. Only prompt for isolation on a fresh repo.
1532
1736
  let isolation = opts.isolation === true
1737
+ let workspaceMode = 'worktree'
1533
1738
  if (interactive && !isExistingSetup(dir)) {
1534
1739
  const { promptSetup } = require('./prompts.js')
1535
- isolation = (await promptSetup({ isolationSeed: isolation })).isolation
1740
+ const answers = await promptSetup({ isolationSeed: isolation })
1741
+ isolation = answers.isolation
1742
+ workspaceMode = answers.mode
1536
1743
  }
1537
- 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
+ })
1538
1752
  break
1539
1753
  }
1540
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 }