@skitterbyte/skitterspec 16.10.0 → 18.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
@@ -22,6 +22,7 @@ const {
22
22
  resolveBaseBranch,
23
23
  resolvePrimaryCheckout,
24
24
  assertPrimaryOnMain,
25
+ currentBranch,
25
26
  repoInfo,
26
27
  expandTokens,
27
28
  splitPrefix,
@@ -37,10 +38,10 @@ const {
37
38
  planAbort,
38
39
  } = require('./env/live.js')
39
40
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
40
- const { planUp } = require('./env/provision.js')
41
- const { planDown } = require('./env/teardown.js')
41
+ const { planUp, planCheckoutUp } = require('./env/provision.js')
42
+ const { planDown, planDownCheckout } = require('./env/teardown.js')
42
43
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
43
- const { planIntegrate } = require('./env/integrate.js')
44
+ const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
44
45
  const { planHotfixLand } = require('./env/hotfix.js')
45
46
  const { planDev } = require('./env/dev.js')
46
47
  const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
@@ -94,6 +95,11 @@ Usage:
94
95
  hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
95
96
  status list provisioned specs + port blocks
96
97
  resolve <spec> print resolved slug/type/branch/paths
98
+ skitterspec gating <cmd> Release-gating check (opt-in; needs
99
+ specs/.core/gating.config.json). Subcommands:
100
+ check [spec] report specs with no recorded gating
101
+ decision (--all, --json). Advisory:
102
+ always exits 0, never blocks.
97
103
  skitterspec --help Show this help
98
104
  skitterspec --version Print version
99
105
 
@@ -105,6 +111,8 @@ Options (init / update):
105
111
  --diff (update) Show the upstream changes each customized
106
112
  file declined, as a unified diff
107
113
  --dir <path> Target project dir (default: positional arg or cwd)
114
+ --gating (init) Adopt release gating: each spec records whether
115
+ it ships behind a feature flag
108
116
  --no-claude-md Skip creating/patching CLAUDE.md
109
117
  --yes, -y Accept defaults; skip the interactive setup prompts
110
118
  --isolation / --no-isolation Enable/skip per-spec isolation (a git
@@ -139,6 +147,10 @@ function parse(argv) {
139
147
  else if (a === '--yes' || a === '-y') opts.yes = true
140
148
  else if (a === '--isolation') opts.isolation = true
141
149
  else if (a === '--no-isolation') opts.isolation = false
150
+ else if (a === '--gating') opts.gating = true
151
+ else if (a === '--no-gating') opts.gating = false
152
+ else if (a === '--all') opts.all = true
153
+ else if (a === '--json') opts.json = true
142
154
  else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
143
155
  else if (a === '--resync') opts.resync = true
144
156
  else if (a === '--reset') opts.reset = true
@@ -233,9 +245,228 @@ function specEnvStatus(dir, config) {
233
245
  // the /spec-env skill executes (git worktree add, docker compose up, .env,
234
246
  // opener). This creates no worktree and starts no stack — the caller runs the
235
247
  // printed commands. Keep the output's verb honest about that.
248
+
249
+ // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
250
+ // can; anything we cannot parse confidently is returned as-is, which makes it
251
+ // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
252
+ // safe direction to be wrong in.
253
+ function unquotePath(p) {
254
+ if (!p.startsWith('"') || !p.endsWith('"')) return p
255
+ try {
256
+ return JSON.parse(p)
257
+ } catch {
258
+ return p
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Repo-relative paths of everything uncommitted. Returns null when git could not
264
+ * be read at all — the caller must treat that as "nobody looked", never "clean".
265
+ *
266
+ * Two prefix-free listings rather than `git status --porcelain`, deliberately.
267
+ * Porcelain prefixes every path with a two-character status field, and the shared
268
+ * git reader TRIMS its output — which eats the leading space of the first line
269
+ * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
270
+ * These emit bare paths, so there is no offset to get wrong. `--others` also
271
+ * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
272
+ * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
273
+ * an ancestor attributable to no single spec, and so refusing the very tree this
274
+ * gate exists to accept. Both were found by running it, not by reading it.
275
+ */
276
+ function dirtyPaths(git) {
277
+ const lists = [
278
+ git(['diff', '--name-only', 'HEAD']),
279
+ git(['ls-files', '--others', '--exclude-standard']),
280
+ ]
281
+ if (lists.every((l) => l === null)) return null
282
+ const out = []
283
+ for (const list of lists) {
284
+ if (!list) continue
285
+ for (const line of list.split('\n')) {
286
+ const q = line.trim()
287
+ if (q) out.push(unquotePath(q))
288
+ }
289
+ }
290
+ return out
291
+ }
292
+
293
+ // Is this spec new to git? Asked directly, so the commit subject does not depend
294
+ // on the shape of `git status` output.
295
+ function specIsUntracked(dir, git, spec) {
296
+ const rel = path.relative(dir, spec.path).split(path.sep).join('/')
297
+ const tracked = git(['ls-files', '--', rel])
298
+ return tracked === null ? null : tracked.length === 0
299
+ }
300
+
301
+ /**
302
+ * Is this spec present in the commit the worktree will fork from?
303
+ *
304
+ * A worktree forks from HEAD (`git worktree add -b`), so a spec absent there
305
+ * yields a branch missing the very spec it is for — and a clean working tree
306
+ * cannot detect that, because the spec may be perfectly well committed elsewhere.
307
+ * This asks the positive question rather than inferring it from cleanliness.
308
+ *
309
+ * A HOTFIX IS EXEMPT, and not as an edge case: it forks from a released tag
310
+ * (`spec.baseRef`) that by definition predates the spec describing the fix, so
311
+ * the spec is *supposed* to be absent there. Checking it would refuse every
312
+ * hotfix — an accusation aimed squarely at correct behaviour.
313
+ *
314
+ * Returns `{ onFork, foundOn }`; `onFork: null` means "cannot tell" (a hotfix, or
315
+ * a git that would not answer), which the planner routes to carrying on.
316
+ */
317
+ function specOnForkPoint(dir, git, spec) {
318
+ if (spec.baseRef) return { onFork: null, foundOn: null }
319
+ const rel = path.relative(dir, spec.path).split(path.sep).join('/')
320
+ if (git(['cat-file', '-e', `HEAD:${rel}/00-overview.md`]) !== null) {
321
+ return { onFork: true, foundOn: null }
322
+ }
323
+ // Best-effort: name the branch that does have it, so the refusal is actionable.
324
+ let foundOn = null
325
+ const sha = git(['log', '--all', '--format=%H', '-1', '--', rel])
326
+ if (sha) {
327
+ const branches = git(['branch', '--contains', sha, '--format=%(refname:short)'])
328
+ if (branches) foundOn = branches.split('\n').map((b) => b.trim()).filter(Boolean)[0] || null
329
+ }
330
+ return { onFork: false, foundOn }
331
+ }
332
+
333
+
334
+ // Say what is about to be committed, and why it qualified. The commit is planned
335
+ // on the operator's behalf, so it is never allowed to be a surprise: the paths are
336
+ // listed before the commands that stage them.
337
+ function specCommitLines(plan, folder) {
338
+ if (!plan.specCommit) return []
339
+ const out = ['', ` uncommitted, and all of it is ${folder}'s — it will be committed first:`]
340
+ for (const p of plan.specCommit.paths) out.push(` ${p}`)
341
+ return out
342
+ }
343
+
344
+ // `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
345
+ // planner, and prints the plan or the refusal.
346
+
347
+ /**
348
+ * `skitterspec gating check [spec] [--all] [--json]`
349
+ *
350
+ * ADVISORY BY CONSTRUCTION. It reports and exits 0 — always, including when it
351
+ * finds something. The point of release gating is that the decision is recorded
352
+ * and reviewable, not that a machine enforces it: a project that has not decided
353
+ * yet is not broken, and a check that stopped someone's work over a missing
354
+ * header would be a worse failure than the omission it names.
355
+ */
356
+ function gatingCheck(dir, argv) {
357
+ const { opts, positional } = parse(argv)
358
+ const { checkGating, activeSpecs } = require('./gating.js')
359
+
360
+ let specs = null
361
+ if (positional.length && !opts.all) {
362
+ const name = path.basename(positional[0])
363
+ const found = activeSpecs(dir).filter((s) => s.folder === name)
364
+ if (!found.length) {
365
+ // Not an accusation: a name that matches no ACTIVE spec is usually a
366
+ // finished one, which gating never covers anyway.
367
+ process.stdout.write(`gating: no active spec named ${name} — nothing to check.\n`)
368
+ return
369
+ }
370
+ specs = found
371
+ }
372
+
373
+ const result = checkGating(dir, specs)
374
+
375
+ if (opts.json) {
376
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n')
377
+ return
378
+ }
379
+ if (!result.configured) {
380
+ process.stdout.write('gating: not configured — nothing to check.\n')
381
+ return
382
+ }
383
+ if (!result.findings.length) {
384
+ process.stdout.write(
385
+ `gating: ${result.checked} spec(s) checked — every one records a decision.\n`,
386
+ )
387
+ return
388
+ }
389
+ const lines = []
390
+ for (const f of result.findings) {
391
+ lines.push(
392
+ f.kind === 'missing'
393
+ ? ` ${f.folder} (${f.bucket}): no Gating: header`
394
+ : ` ${f.folder} (${f.bucket}): Gating: "${f.raw}" says nothing — a bare "none" is not a reason`,
395
+ )
396
+ }
397
+ lines.push('')
398
+ lines.push(' decide, then record it on 00-overview.md beside Stack:')
399
+ lines.push(' > **Gating:** <flag name — or "none: <one-line reason>">')
400
+ if (result.guidance) lines.push(` see ${result.guidance}`)
401
+ process.stdout.write(
402
+ `gating: ${result.findings.length} of ${result.checked} spec(s) record no decision\n` +
403
+ lines.join('\n') +
404
+ '\n',
405
+ )
406
+ }
407
+
408
+ function specEnvUpCheckout(dir, config, spec) {
409
+ const git = gitReader(dir)
410
+ const primary = assertPrimaryOnMain(config, git)
411
+ const base = resolveBaseBranch(config, git)
412
+ const status = git(['status', '--porcelain', '-uall'])
413
+ const onFork = specOnForkPoint(dir, git, spec)
414
+
415
+ const plan = planCheckoutUp(
416
+ spec,
417
+ {
418
+ current: primary.branch,
419
+ base,
420
+ onBase: primary.onBase,
421
+ // A null status means git could not be read at all. Treated as NOT clean:
422
+ // the harmless outcome of being wrong is a refusal the operator can act
423
+ // on, and the harmful one is carrying their work onto a new branch.
424
+ clean: status !== null && status.length === 0,
425
+ dirtyPaths: dirtyPaths(git),
426
+ specOnFork: onFork.onFork,
427
+ specFoundOn: onFork.foundOn,
428
+ forkRef: primary.branch || 'HEAD',
429
+ specUntracked: specIsUntracked(dir, git, spec),
430
+ branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
431
+ checkoutPath: dir,
432
+ },
433
+ config,
434
+ )
435
+
436
+ if (plan.blocked) {
437
+ process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
438
+ return
439
+ }
440
+
441
+ const out = [
442
+ `spec-env up: ${spec.folder} ` +
443
+ (plan.attached ? '(already on this branch — nothing to do)' : '(plan — nothing created yet)'),
444
+ '',
445
+ ` mode: checkout (branch built in the primary checkout)`,
446
+ ` checkout: ${plan.checkoutPath}`,
447
+ ` branch: ${plan.branch}`,
448
+ ' stack: checkout-only (no worktree, no docker, no port block)',
449
+ ]
450
+ out.push(...specCommitLines(plan, spec.folder))
451
+ if (plan.commands.length) {
452
+ out.push('')
453
+ out.push(' to provision, run:')
454
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
455
+ }
456
+ process.stdout.write(out.join('\n') + '\n')
457
+ }
458
+
236
459
  function specEnvUp(dir, config, specArg) {
237
460
  const spec = resolveSpecWithWorktree(dir, config, specArg)
238
461
 
462
+ // Checkout mode: the branch is built in the primary checkout, so none of the
463
+ // worktree machinery below applies — no slot, no trust entry, no bootstrap and
464
+ // no opener. Handled first precisely so none of that runs by accident.
465
+ if (config.mode === 'checkout') {
466
+ specEnvUpCheckout(dir, config, spec)
467
+ return
468
+ }
469
+
239
470
  // Live-safe: if this spec is already live on the primary checkout (its branch was
240
471
  // branch-switched in by `live take`), a `git worktree add` would fail — the branch
241
472
  // is checked out there. Point the operator at the primary checkout rather than
@@ -251,7 +482,7 @@ function specEnvUp(dir, config, specArg) {
251
482
 
252
483
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
253
484
  // 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.
485
+ // every provision for teammates who only cloned and ran /spec-start.
255
486
  const worktreeRootAbs = path.dirname(spec.worktreePath)
256
487
  const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
257
488
 
@@ -272,7 +503,24 @@ function specEnvUp(dir, config, specArg) {
272
503
  attached = fs.existsSync(spec.worktreePath)
273
504
  }
274
505
 
275
- const plan = planUp(spec, { slot, attached }, config)
506
+ // The tree gate: the same facts the checkout planner gets. A worktree forks
507
+ // from base, so an uncommitted spec would produce a branch without it.
508
+ const upGit = gitReader(dir)
509
+ const upStatus = upGit(['status', '--porcelain'])
510
+ const upOnFork = specOnForkPoint(dir, upGit, spec)
511
+ const plan = planUp(spec, { slot, attached }, config, {
512
+ clean: upStatus !== null && upStatus.length === 0,
513
+ dirtyPaths: dirtyPaths(upGit),
514
+ specOnFork: upOnFork.onFork,
515
+ specFoundOn: upOnFork.foundOn,
516
+ forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
517
+ specUntracked: specIsUntracked(dir, upGit, spec),
518
+ })
519
+
520
+ if (plan.blocked) {
521
+ process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
522
+ return
523
+ }
276
524
 
277
525
  const out = []
278
526
  // `up` is a planner: it prints commands for the caller to run and creates no
@@ -295,6 +543,10 @@ function specEnvUp(dir, config, specArg) {
295
543
  } else {
296
544
  out.push(' stack: worktree-only (no docker, no port block)')
297
545
  }
546
+ // The loader falls back silently on an unrecognised `mode`, so this line is
547
+ // the operator's only evidence of which mode actually resolved — print it
548
+ // whenever it was set explicitly, right or wrong.
549
+ out.push(' mode: worktree (each spec gets its own checkout)')
298
550
  if (trust.reason === 'malformed') {
299
551
  out.push(
300
552
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -306,6 +558,7 @@ function specEnvUp(dir, config, specArg) {
306
558
  `(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
307
559
  )
308
560
  }
561
+ out.push(...specCommitLines(plan, spec.folder))
309
562
  out.push('')
310
563
  out.push(' to provision, run:')
311
564
  for (const cmd of plan.commands) out.push(` ${cmd}`)
@@ -429,12 +682,45 @@ function compactTimestamp() {
429
682
  // when the spec was never provisioned / already torn down. Deliberately does NOT
430
683
  // touch the trusted worktree root in .claude/settings.local.json — that entry is
431
684
  // 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).
685
+ // would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
433
686
  function specEnvDown(dir, config, specArg, flags) {
434
687
  const spec = resolveSpecWithWorktree(dir, config, specArg)
435
688
 
436
689
  // A worktree-only spec never held a slot but its worktree still needs removing,
437
690
  // so "nothing to do" means neither a slot nor a worktree exists.
691
+ // Checkout mode first: there is no worktree and no registry slot by design, so
692
+ // the "not provisioned" guard below would report a live spec as absent.
693
+ if (config.mode === 'checkout') {
694
+ const dgit = gitReader(dir)
695
+ const dbase = resolveBaseBranch(config, dgit)
696
+ if (dgit(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) === null) {
697
+ process.stdout.write(`spec-env down: ${spec.folder} has no branch — nothing to do.\n`)
698
+ return
699
+ }
700
+ const dst = dgit(['status', '--porcelain'])
701
+ const contains = dgit(['branch', '--contains', spec.branch, '--list', dbase])
702
+ const dplan = planDownCheckout(spec, config, flags, {
703
+ dirty: dst === null || dst.length > 0,
704
+ landed: Boolean(contains && contains.trim()),
705
+ onBranch: dgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
706
+ base: dbase,
707
+ checkoutPath: dir,
708
+ })
709
+ if (dplan.blocked) {
710
+ process.stdout.write(
711
+ `spec-env down: blocked — ${dplan.reason}.\n` +
712
+ 'Re-run with --force to tear down anyway (deletes the branch).\n',
713
+ )
714
+ return
715
+ }
716
+ const dout = [`spec-env down: ${spec.folder}`, '',
717
+ ' mode: checkout (no worktree, no slot, no volumes)',
718
+ ` branch: ${dplan.branch}`, '', ' run these:']
719
+ for (const cmd of dplan.commands) dout.push(` ${cmd}`)
720
+ process.stdout.write(dout.join('\n') + '\n')
721
+ return
722
+ }
723
+
438
724
  const registry = readRegistry(dir, config)
439
725
  const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
440
726
  if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
@@ -566,7 +852,7 @@ function liveWorktreePaths(dir) {
566
852
  * bucket disappears the moment it empties.
567
853
  *
568
854
  * 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*
855
+ * hint → throw, listing them; none → throw, pointing at /spec-start. *Cannot tell*
570
856
  * never becomes a guess.
571
857
  *
572
858
  * BLIND SPOT: a spec taken live with `/spec-live` has had its branch moved into
@@ -603,7 +889,7 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
603
889
  if (provisioned.length === 0) {
604
890
  throw new Error(
605
891
  'no spec given, and no spec has a worktree — name one explicitly, or run ' +
606
- '/spec-go to provision it.',
892
+ '/spec-start to provision it.',
607
893
  )
608
894
  }
609
895
  throw new Error(
@@ -764,6 +1050,39 @@ function specEnvIntegrate(dir, config, specArg) {
764
1050
  const spec = resolveSpecWithWorktree(dir, config, specArg)
765
1051
  const base = resolveBaseBranch(config, gitReader(dir))
766
1052
 
1053
+ // Checkout mode short-circuits everything below. The live handling in
1054
+ // particular reads "primary is on the spec's branch" as a live session — true
1055
+ // in worktree mode, and simply where the branch LIVES in checkout mode, so it
1056
+ // would refuse to land a spec sitting exactly where it belongs.
1057
+ if (config.mode === 'checkout') {
1058
+ const cgit = gitReader(dir)
1059
+ const cst = cgit(['status', '--porcelain'])
1060
+ const cahead = cgit(['rev-list', '--count', `${base}..${spec.branch}`])
1061
+ const cplan = planIntegrateCheckout(spec, config, {
1062
+ dirty: cst === null || cst.length > 0,
1063
+ base,
1064
+ aheadOfBase: cahead !== null && Number(cahead) > 0,
1065
+ checkoutPath: dir,
1066
+ onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
1067
+ })
1068
+ if (cplan.blocked) {
1069
+ process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
1070
+ return
1071
+ }
1072
+ if (cplan.noop) {
1073
+ process.stdout.write(
1074
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
1075
+ )
1076
+ return
1077
+ }
1078
+ const cout = [`spec-env integrate: ${spec.folder}`, '', ' mode: checkout',
1079
+ ` base: ${base}`, ` branch: ${cplan.branch}`, '',
1080
+ ' run these (abort the rebase on conflict):']
1081
+ for (const cmd of cplan.commands) cout.push(` ${cmd}`)
1082
+ process.stdout.write(cout.join('\n') + '\n')
1083
+ return
1084
+ }
1085
+
767
1086
  // Live-aware: if this spec is live on the primary checkout (branch-switched by
768
1087
  // `live take`), end the live session first — release back to base, re-isolate the
769
1088
  // branch, clear the receipt — so the normal rebase→ff plan below applies
@@ -798,7 +1117,7 @@ function specEnvIntegrate(dir, config, specArg) {
798
1117
  const liveWtGit = gitReader(spec.worktreePath)
799
1118
  if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
800
1119
  // 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.
1120
+ // a build that committed in the worktree) would be abandoned by the re-isolate `switch` below.
802
1121
  const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
803
1122
  const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
804
1123
  if (stranded !== null && Number(stranded) > 0) {
@@ -1050,6 +1369,16 @@ function proxyProcFor(config, routesFileAbs) {
1050
1369
  // bundled proxy pointing at that spec's warm dev servers. `connect main` stops
1051
1370
  // the proxy so the primary checkout owns the canonical ports again.
1052
1371
  async function specEnvConnect(dir, config, specArg) {
1372
+ // Both verbs exist to route around the work living somewhere other than the
1373
+ // checkout you are in — a proxy to a second stack, or a temporary branch swap.
1374
+ // Checkout mode closes that gap permanently, so there is nothing to route.
1375
+ if (config.mode === 'checkout') {
1376
+ process.stdout.write(
1377
+ 'spec-env connect: not applicable in checkout mode — the spec is built in the ' +
1378
+ 'primary checkout, so your dev server already serves it on the canonical ports.\\n',
1379
+ )
1380
+ return
1381
+ }
1053
1382
  const sdir = stateDirLabel(config)
1054
1383
  const abs = (rel) => path.resolve(dir, rel)
1055
1384
  const routesFile = `${sdir}/proxy.json`
@@ -1151,6 +1480,17 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
1151
1480
  // receipt is advisory metadata. `status` is read-only; `take` performs the switch
1152
1481
  // (release/abort land in a later phase).
1153
1482
  async function specEnvLive(dir, config, positional) {
1483
+ // Both verbs exist to route around the work living somewhere other than the
1484
+ // checkout you are in — a proxy to a second stack, or a temporary branch swap.
1485
+ // Checkout mode closes that gap permanently, so there is nothing to route.
1486
+ if (config.mode === 'checkout') {
1487
+ process.stdout.write(
1488
+ 'spec-env live: not applicable in checkout mode — the spec branch is already ' +
1489
+ 'checked out here. `mode: checkout` is the permanent form of what live overlay ' +
1490
+ 'does temporarily.\\n',
1491
+ )
1492
+ return
1493
+ }
1154
1494
  const { action, specArg } = liveGrammar(dir, config, positional)
1155
1495
  switch (action) {
1156
1496
  case 'status':
@@ -1210,6 +1550,10 @@ async function specEnvLiveTake(dir, config, specArg) {
1210
1550
  const status = primaryGit(['status', '--porcelain'])
1211
1551
  const clean = status !== null && status.length === 0
1212
1552
  const worktreeExists = fs.existsSync(spec.worktreePath)
1553
+ // The tree the rebase actually runs in. Unreadable → treated as dirty: being
1554
+ // wrong that way costs a message, the other way moves work we could not see.
1555
+ const wtStatus = worktreeExists ? gitReader(spec.worktreePath)(['status', '--porcelain']) : ''
1556
+ const worktreeClean = wtStatus !== null && wtStatus.length === 0
1213
1557
  const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
1214
1558
 
1215
1559
  // Diff base...branch to spot migration / dependency changes (best-effort).
@@ -1229,7 +1573,9 @@ async function specEnvLiveTake(dir, config, specArg) {
1229
1573
  const plan = planTake(spec, config, {
1230
1574
  primary,
1231
1575
  primaryPath: dir,
1576
+ inFlight: (readReceipt(dir, config) || {}).spec || null,
1232
1577
  clean,
1578
+ worktreeClean,
1233
1579
  worktreeExists,
1234
1580
  base,
1235
1581
  baseMainCommit,
@@ -1249,10 +1595,19 @@ async function specEnvLiveTake(dir, config, specArg) {
1249
1595
  // Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
1250
1596
  const reb = runGit(spec.worktreePath, ['rebase', base])
1251
1597
  if (!reb.ok) {
1252
- runGit(spec.worktreePath, ['rebase', '--abort'])
1598
+ // A rebase fails two ways and they need different answers. It can REFUSE TO
1599
+ // START (unstaged changes, a missing base) — nothing to abort, nothing to
1600
+ // resolve — or start and CONFLICT. Calling both "hit conflicts" sent people
1601
+ // hunting a conflict that did not exist, and `--abort` on a rebase that
1602
+ // never began discarded git's own explanation of what was actually wrong.
1603
+ const started = runGit(spec.worktreePath, ['rebase', '--show-current-patch']).ok
1604
+ if (started) runGit(spec.worktreePath, ['rebase', '--abort'])
1253
1605
  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`,
1606
+ started
1607
+ ? `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
1608
+ `resolve them in ${spec.worktreePath}, then retry.\n`
1609
+ : `spec-env live take: rebase of ${spec.branch} onto ${base} could not start — ` +
1610
+ `git said:\n ${reb.err.split('\n').join('\n ')}\n`,
1256
1611
  )
1257
1612
  return
1258
1613
  }
@@ -1400,7 +1755,7 @@ async function specEnvLiveAbort(dir, config) {
1400
1755
  function specEnvLiveStatus(dir, config, specArg) {
1401
1756
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
1402
1757
 
1403
- // Per-spec query (`live status <spec>`): a clear yes/no verdict the /spec-go
1758
+ // Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
1404
1759
  // skill branches on to decide whether to skip worktree provisioning and work in
1405
1760
  // the primary checkout. The stable `live: yes|no` line is the machine seam.
1406
1761
  if (specArg) {
@@ -1423,9 +1778,23 @@ function specEnvLiveStatus(dir, config, specArg) {
1423
1778
  const state = onBase
1424
1779
  ? 'on base — free'
1425
1780
  : `feature in control — not on ${baseBranch}`
1781
+
1782
+ // `in-flight:` is a MACHINE SEAM, like the per-spec `live:` line above, and
1783
+ // `/spec-next` reads it to decide which spec it is allowed to build. It answers
1784
+ // in three states rather than two, because "cannot tell" is real here: a branch
1785
+ // switched by hand carries no receipt, so the spec is unknown even though the
1786
+ // checkout is plainly busy. Reporting that as `none` would invite building the
1787
+ // wrong spec; reporting the branch says what is true and lets the caller stop.
1788
+ const inFlight = onBase
1789
+ ? 'none — the workbench is free'
1790
+ : receipt && receipt.spec
1791
+ ? `${receipt.spec} (branch ${branch || '(detached)'})`
1792
+ : `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
1793
+
1426
1794
  process.stdout.write(
1427
1795
  'spec-env live:\n' +
1428
1796
  ` primary: ${branch || '(detached)'} (${state})\n` +
1797
+ ` in-flight: ${inFlight}\n` +
1429
1798
  ` receipt: ${summarizeReceipt(receipt)}\n`,
1430
1799
  )
1431
1800
  }
@@ -1513,6 +1882,14 @@ async function run(argv) {
1513
1882
 
1514
1883
  const [cmd, ...rest] = argv
1515
1884
 
1885
+ if (cmd === 'gating') {
1886
+ const [sub, ...gArgs] = rest
1887
+ const gDir = process.cwd()
1888
+ if (sub === 'check') gatingCheck(gDir, gArgs)
1889
+ else process.stdout.write('Usage: skitterspec gating check [spec] [--all] [--json]\n')
1890
+ return
1891
+ }
1892
+
1516
1893
  if (cmd === 'spec-env') {
1517
1894
  await specEnv(rest)
1518
1895
  return
@@ -1560,11 +1937,24 @@ async function run(argv) {
1560
1937
  // Fresh repo (or create-missing): isolation defaults OFF; a flag or an
1561
1938
  // interactive "yes" opts in. Only prompt for isolation on a fresh repo.
1562
1939
  let isolation = opts.isolation === true
1940
+ let workspaceMode = 'worktree'
1941
+ let gating = opts.gating === true
1563
1942
  if (interactive && !isExistingSetup(dir)) {
1564
1943
  const { promptSetup } = require('./prompts.js')
1565
- isolation = (await promptSetup({ isolationSeed: isolation })).isolation
1944
+ const answers = await promptSetup({ isolationSeed: isolation, gatingSeed: gating })
1945
+ isolation = answers.isolation
1946
+ workspaceMode = answers.mode
1947
+ gating = answers.gating
1566
1948
  }
1567
- await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
1949
+ await init({
1950
+ dir,
1951
+ force: opts.force,
1952
+ claudeMd: opts.claudeMd,
1953
+ mode: 'init',
1954
+ isolation,
1955
+ workspaceMode,
1956
+ gating,
1957
+ })
1568
1958
  break
1569
1959
  }
1570
1960
  case 'update':