@skitterbyte/skitterspec 11.0.0 → 13.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
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { execFileSync } = require('child_process')
6
- const { init } = require('./init.js')
6
+ const { init, resync, reset, isExistingSetup } = require('./init.js')
7
7
  const {
8
8
  detectReleaseTooling,
9
9
  removeReleaseTooling,
@@ -21,14 +21,27 @@ const {
21
21
  resolveSpec,
22
22
  resolveBaseBranch,
23
23
  resolvePrimaryCheckout,
24
+ assertPrimaryOnMain,
24
25
  repoInfo,
25
26
  expandTokens,
26
27
  splitPrefix,
27
28
  } = require('./env/resolve.js')
29
+ const {
30
+ readReceipt,
31
+ writeReceipt,
32
+ clearReceipt,
33
+ summarizeReceipt,
34
+ migrationsHit,
35
+ planTake,
36
+ planRelease,
37
+ planAbort,
38
+ } = require('./env/live.js')
28
39
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
29
40
  const { planUp } = require('./env/provision.js')
30
41
  const { planDown } = require('./env/teardown.js')
42
+ const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
31
43
  const { planIntegrate } = require('./env/integrate.js')
44
+ const { planHotfixLand } = require('./env/hotfix.js')
32
45
  const { planDev } = require('./env/dev.js')
33
46
  const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
34
47
  const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
@@ -39,23 +52,31 @@ const HELP = `skitterspec — spec-driven-development for Claude Code
39
52
 
40
53
  Usage:
41
54
  skitterspec init [dir] Install the spec lifecycle skills, rule, and specs/
42
- folders into a project
43
- skitterspec update [dir] Re-copy skills + rule (overwrites), leaves specs/
44
- and specs/.core/ config alone
55
+ folders. On an already-set-up repo it detects that
56
+ and offers resync / start-again / leave (interactive)
57
+ non-interactively it just adds anything missing.
58
+ skitterspec update [dir] Resync managed files to the latest, keeping your
59
+ edits (--force to overwrite). Leaves specs/ + live
60
+ .core config alone.
45
61
  skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
46
62
  specs/.core/env.config.json). Subcommands:
47
63
  up <spec> plan a worktree + Docker stack + opener
48
64
  down <spec> tear down (guards; --keep-volumes, --force)
65
+ prune reap orphaned test-DB volumes (--older-than <days>)
49
66
  dev up <spec> start host dev servers on the spec's ports
50
67
  dev down <spec> stop the spec's host dev servers
51
68
  connect <spec> expose a spec on the canonical ports (main = off)
52
69
  integrate <spec> plan rebase + fast-forward onto the base branch
70
+ hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
53
71
  status list provisioned specs + port blocks
54
72
  resolve <spec> print resolved slug/type/branch/paths
55
73
  skitterspec --help Show this help
56
74
  skitterspec --version Print version
57
75
 
58
76
  Options (init / update):
77
+ --resync (init) Update managed files to latest, keep your edits
78
+ --reset (init) Start again: reset managed scaffolding fresh
79
+ (needs --yes; never touches your specs or config)
59
80
  --force Overwrite skill/rule/script files that already exist
60
81
  --dir <path> Target project dir (default: positional arg or cwd)
61
82
  --no-claude-md Skip creating/patching CLAUDE.md
@@ -80,6 +101,8 @@ function parse(argv) {
80
101
  yes: false,
81
102
  isolation: undefined,
82
103
  removeReleaseTooling: false,
104
+ resync: false,
105
+ reset: false,
83
106
  }
84
107
  const positional = []
85
108
  for (let i = 0; i < argv.length; i++) {
@@ -90,6 +113,8 @@ function parse(argv) {
90
113
  else if (a === '--isolation') opts.isolation = true
91
114
  else if (a === '--no-isolation') opts.isolation = false
92
115
  else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
116
+ else if (a === '--resync') opts.resync = true
117
+ else if (a === '--reset') opts.reset = true
93
118
  else if (a === '--dir') opts.dir = argv[++i]
94
119
  else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
95
120
  else positional.push(a)
@@ -250,7 +275,9 @@ function gitReader(cwd) {
250
275
  // the resolved integration branch; `merged` is true when HEAD is already an
251
276
  // ancestor of it (fully landed), which lets teardown skip the unpushed guard.
252
277
  function worktreeGitState(worktreePath, base) {
253
- if (!fs.existsSync(worktreePath)) return { dirty: false, unpushed: false, merged: true }
278
+ if (!fs.existsSync(worktreePath)) {
279
+ return { dirty: false, unpushed: false, merged: true, reachableFromTag: false }
280
+ }
254
281
  const git = gitReader(worktreePath)
255
282
 
256
283
  const status = git(['status', '--porcelain'])
@@ -272,7 +299,13 @@ function worktreeGitState(worktreePath, base) {
272
299
  // exits 0 when true; gitReader maps a non-zero exit to null.
273
300
  const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
274
301
 
275
- return { dirty, unpushed, merged }
302
+ // reachableFromTag = HEAD is captured by a tag (the deploy tag from `hotfix
303
+ // land`). A hotfix branch is never merged into base, so this is what tells
304
+ // teardown its commits are safely recoverable.
305
+ const pointing = git(['tag', '--points-at', 'HEAD'])
306
+ const reachableFromTag = pointing !== null && pointing.length > 0
307
+
308
+ return { dirty, unpushed, merged, reachableFromTag }
276
309
  }
277
310
 
278
311
  // A deterministic-enough compact timestamp for backup filenames (CLI-only; the
@@ -337,6 +370,166 @@ function specEnvDown(dir, config, specArg, flags) {
337
370
  process.stdout.write(out.join('\n') + '\n')
338
371
  }
339
372
 
373
+ // --- prune: reap orphaned per-spec test-DB volumes -------------------------
374
+
375
+ // Live Docker volumes in the repo namespace (`{repoSlug}_…`). Returns
376
+ // { ok, names }: ok:false means docker is unavailable / errored (non-fatal — the
377
+ // caller reports and skips). The `name=` filter is a substring match, so we
378
+ // re-check the prefix in the pure planner.
379
+ function listRepoVolumes(repoSlug) {
380
+ try {
381
+ const out = execFileSync(
382
+ 'docker',
383
+ ['volume', 'ls', '--format', '{{.Name}}', '--filter', `name=${repoSlug}_`],
384
+ { stdio: ['ignore', 'pipe', 'pipe'] },
385
+ )
386
+ .toString()
387
+ .trim()
388
+ const names = out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : []
389
+ return { ok: true, names }
390
+ } catch (error) {
391
+ const err = (error.stderr && error.stderr.toString().trim()) || error.message
392
+ return { ok: false, names: [], err }
393
+ }
394
+ }
395
+
396
+ // Map orphan-candidate volume names → creation epoch-ms via `docker volume
397
+ // inspect`. Unknown/unparseable timestamps stay null (the planner keeps them).
398
+ function volumeCreatedAt(names) {
399
+ const byName = new Map()
400
+ if (!names.length) return byName
401
+ try {
402
+ const out = execFileSync(
403
+ 'docker',
404
+ ['volume', 'inspect', '--format', '{{.Name}}\t{{.CreatedAt}}', ...names],
405
+ { stdio: ['ignore', 'pipe', 'pipe'] },
406
+ )
407
+ .toString()
408
+ .trim()
409
+ for (const line of out.split('\n')) {
410
+ const [name, created] = line.split('\t')
411
+ const ms = created ? Date.parse(created.trim()) : NaN
412
+ if (name) byName.set(name.trim(), Number.isNaN(ms) ? null : ms)
413
+ }
414
+ } catch {
415
+ // Inspect failed wholesale → treat every candidate as unknown-age (kept).
416
+ }
417
+ return byName
418
+ }
419
+
420
+ // Absolute paths of every checkout git knows about (primary + all worktrees).
421
+ function liveWorktreePaths(dir) {
422
+ const out = gitReader(dir)(['worktree', 'list', '--porcelain'])
423
+ const paths = new Set()
424
+ if (out == null) return paths
425
+ for (const line of out.split('\n')) {
426
+ if (line.startsWith('worktree ')) {
427
+ paths.add(path.resolve(line.slice('worktree '.length).trim()))
428
+ }
429
+ }
430
+ return paths
431
+ }
432
+
433
+ // Every spec folder name found under specs/* across the given checkout roots.
434
+ // An in-progress spec lives on its *worktree branch*, not the primary checkout,
435
+ // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
436
+ function collectSpecFolders(roots) {
437
+ const folders = new Set()
438
+ for (const root of roots) {
439
+ for (const bucket of ['backlog', 'in-progress', 'complete', 'cancelled']) {
440
+ let entries
441
+ try {
442
+ entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
443
+ } catch {
444
+ continue
445
+ }
446
+ for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
447
+ }
448
+ }
449
+ return folders
450
+ }
451
+
452
+ // Resolve every spec folder (found in the primary checkout OR any worktree) to
453
+ // { folder, slug, worktreePath }. `searchDirs` lets resolveSpec locate a spec
454
+ // that was authored on its branch and never committed to the primary checkout.
455
+ function allSpecs(dir, config, worktreePaths) {
456
+ const searchDirs = [...worktreePaths]
457
+ const specs = []
458
+ for (const folder of collectSpecFolders([dir, ...searchDirs])) {
459
+ try {
460
+ const spec = resolveSpec(folder, dir, config, { searchDirs })
461
+ specs.push({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
462
+ } catch {
463
+ // Unresolvable folder (not a real spec) — skip.
464
+ }
465
+ }
466
+ return specs
467
+ }
468
+
469
+ // Prune: reconcile namespace volumes against specs that still have a worktree and
470
+ // print the `docker volume rm` commands for the orphans. Liveness keys off the
471
+ // worktree, NOT the registry (a declined teardown leaves a stale slot behind), so
472
+ // this correctly reaps those and frees their stale slots. Destructive removal is
473
+ // executed by the caller (skill) after confirmation — the CLI only plans + writes
474
+ // the registry, mirroring `spec-env down`.
475
+ function specEnvPrune(dir, config, flags) {
476
+ const { repoSlug } = repoInfo(dir)
477
+
478
+ const vols = listRepoVolumes(repoSlug)
479
+ if (!vols.ok) {
480
+ process.stdout.write(
481
+ `spec-env prune: could not list docker volumes — ${vols.err || 'docker unavailable'}.\n` +
482
+ 'Is Docker running? Nothing pruned.\n',
483
+ )
484
+ return
485
+ }
486
+
487
+ const worktrees = liveWorktreePaths(dir)
488
+ const specs = allSpecs(dir, config, worktrees)
489
+ const liveSlugs = liveSlugsForSpecs(specs, worktrees)
490
+
491
+ const olderThanDays =
492
+ flags && Number.isFinite(flags.olderThanDays) ? flags.olderThanDays : null
493
+ let volumes = vols.names
494
+ let now = null
495
+ if (olderThanDays != null) {
496
+ const createdAt = volumeCreatedAt(vols.names)
497
+ volumes = vols.names.map((name) => ({ name, createdAt: createdAt.get(name) ?? null }))
498
+ now = Date.now()
499
+ }
500
+
501
+ const plan = planPrune(volumes, liveSlugs, { repoSlug, olderThanDays, now })
502
+
503
+ if (!plan.orphans.length) {
504
+ process.stdout.write(
505
+ `spec-env prune: no orphaned volumes in ${repoSlug}_* ` +
506
+ `(${vols.names.length} namespace volume(s), ${liveSlugs.size} live spec(s) protected).\n`,
507
+ )
508
+ return
509
+ }
510
+
511
+ // Reconcile the registry: free the slot of any spec whose volume we're reaping.
512
+ const registry = readRegistry(dir, config)
513
+ const { registry: nextRegistry, freed } = reconcileRegistry(registry, plan.orphans, repoSlug)
514
+ if (freed.length) writeRegistry(dir, config, nextRegistry)
515
+
516
+ const ageNote = olderThanDays != null ? ` older than ${olderThanDays}d` : ''
517
+ const out = []
518
+ out.push(
519
+ `spec-env prune: ${plan.orphans.length} orphaned volume(s)${ageNote} ` +
520
+ `(${liveSlugs.size} live spec(s) protected)`,
521
+ )
522
+ out.push('')
523
+ out.push(' orphans:')
524
+ for (const o of plan.orphans) out.push(` ${o.name}`)
525
+ if (freed.length) out.push(` slots freed: ${freed.join(', ')}`)
526
+ out.push(' backup: none (prune does not back up — orphans have no running DB)')
527
+ out.push('')
528
+ out.push(' run these:')
529
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
530
+ process.stdout.write(out.join('\n') + '\n')
531
+ }
532
+
340
533
  // Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
341
534
  // Queries git for the facts, prints the plan / block / no-op. The /spec-complete
342
535
  // skill executes the printed commands (and aborts a conflicting rebase).
@@ -348,21 +541,43 @@ function specEnvIntegrate(dir, config, specArg) {
348
541
 
349
542
  // `dir` is already anchored on the primary checkout by the dispatch, so it is
350
543
  // both where the spec resolves and the target of the fast-forward — /spec-complete
351
- // can run this from inside the worktree and still land on main.
352
- //
353
- // A spec authored entirely on its branch may not exist in the primary
354
- // checkout's specs/** (it was never committed to base) — but its worktree
355
- // does, and the worktree path is derivable from config without the folder.
356
- // Offer it as a fallback search location so integrate can still find the spec.
357
- const { slug } = splitPrefix(path.basename(specArg))
358
- const { repo, repoSlug } = repoInfo(dir)
359
- const wtTokens = { repo, repoSlug, slug }
360
- const worktreeGuess = path.resolve(
361
- dir,
362
- expandTokens(config.worktree.root, wtTokens),
363
- expandTokens(config.worktree.folderPattern, wtTokens),
364
- )
365
- const spec = resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
544
+ // can run this from inside the worktree and still land on main. A spec authored
545
+ // entirely on its branch may not exist in the primary checkout's specs/** —
546
+ // resolveSpecWithWorktree offers its worktree as a fallback search location.
547
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
548
+ const base = resolveBaseBranch(config, gitReader(dir))
549
+
550
+ // Live-aware: if this spec is live on the primary checkout (branch-switched by
551
+ // `live take`), end the live session first — release back to base, re-isolate the
552
+ // branch, clear the receipt so the normal rebase→ff plan below applies
553
+ // unchanged. Refuse if a *different* spec holds the primary checkout.
554
+ const primary = assertPrimaryOnMain(config, gitReader(dir))
555
+ if (!primary.onBase) {
556
+ if (primary.branch !== spec.branch) {
557
+ process.stdout.write(
558
+ `spec-env integrate: blocked another spec (${primary.branch}) holds the ` +
559
+ 'primary checkout; release it with `/spec-live main` first.\n',
560
+ )
561
+ return
562
+ }
563
+ const pstatus = gitReader(dir)(['status', '--porcelain'])
564
+ if (pstatus === null || pstatus.length > 0) {
565
+ process.stdout.write(
566
+ `spec-env integrate: blocked — commit your live fixes to ${spec.branch} first.\n`,
567
+ )
568
+ return
569
+ }
570
+ const co = runGit(dir, ['checkout', base])
571
+ if (!co.ok) {
572
+ process.stdout.write(`spec-env integrate: could not check out ${base} — ${co.err}\n`)
573
+ return
574
+ }
575
+ if (fs.existsSync(spec.worktreePath)) runGit(spec.worktreePath, ['switch', spec.branch])
576
+ clearReceipt(dir, config)
577
+ process.stdout.write(
578
+ `spec-env integrate: ended live session — ${spec.folder} released to its worktree.\n`,
579
+ )
580
+ }
366
581
 
367
582
  if (!fs.existsSync(spec.worktreePath)) {
368
583
  process.stdout.write(
@@ -371,7 +586,6 @@ function specEnvIntegrate(dir, config, specArg) {
371
586
  return
372
587
  }
373
588
 
374
- const base = resolveBaseBranch(config, gitReader(dir))
375
589
  const wtGit = gitReader(spec.worktreePath)
376
590
  const status = wtGit(['status', '--porcelain'])
377
591
  const dirty = status !== null && status.length > 0
@@ -408,6 +622,93 @@ function specEnvIntegrate(dir, config, specArg) {
408
622
  process.stdout.write(out.join('\n') + '\n')
409
623
  }
410
624
 
625
+ // Land a hotfix: tag the branch with the patch-bumped base tag (the prod deploy
626
+ // tag), cherry-pick the fix onto any extra base tags (test/demo lines) and onto
627
+ // the base branch for the next release. Queries git for the facts, prints the plan
628
+ // / block / no-op. The /spec-complete skill runs the printed commands (aborting a
629
+ // cherry-pick on conflict). NEVER pushes — pushing the deploy tag is the operator's.
630
+ function specEnvHotfix(dir, config, positional, flags) {
631
+ const action = positional[0]
632
+ const specArg = positional[1]
633
+ if (action !== 'land' || !specArg) {
634
+ process.stdout.write('Usage: skitterspec spec-env hotfix land <spec> [--also <tag>]...\n')
635
+ return
636
+ }
637
+
638
+ // A hotfix may be authored entirely on its branch, so fall back to its worktree.
639
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
640
+ if (spec.type !== 'hotfix') {
641
+ process.stdout.write(
642
+ `spec-env hotfix land: ${spec.folder} is not a hotfix — needs Type: Hotfix / a hotfix- prefix.\n`,
643
+ )
644
+ return
645
+ }
646
+ if (!fs.existsSync(spec.worktreePath)) {
647
+ process.stdout.write(`spec-env hotfix land: ${spec.folder} has no worktree — nothing to land.\n`)
648
+ return
649
+ }
650
+
651
+ const base = resolveBaseBranch(config, gitReader(dir))
652
+ const wtGit = gitReader(spec.worktreePath)
653
+ const status = wtGit(['status', '--porcelain'])
654
+ const dirty = status !== null && status.length > 0
655
+ const ahead = wtGit(['rev-list', '--count', `${spec.baseRef}..HEAD`])
656
+ const aheadOfBase = ahead !== null && Number(ahead) > 0
657
+ const tagList = wtGit(['tag', '--list'])
658
+ const existingTags = tagList ? tagList.split('\n').map((s) => s.trim()).filter(Boolean) : []
659
+
660
+ // Extra targets: --also flags first, then any config defaults; drop blanks, the
661
+ // base tag itself, and duplicates.
662
+ const seen = new Set()
663
+ const extraTargets = [...(flags.also || []), ...(config.hotfix.targets || [])].filter((t) => {
664
+ if (!t || t === spec.baseRef || seen.has(t)) return false
665
+ seen.add(t)
666
+ return true
667
+ })
668
+
669
+ let plan
670
+ try {
671
+ plan = planHotfixLand(spec, config, {
672
+ worktreeState: { dirty },
673
+ aheadOfBase,
674
+ fixRange: `${spec.baseRef}..${spec.branch}`,
675
+ mainRepoPath: dir,
676
+ base,
677
+ extraTargets,
678
+ existingTags,
679
+ })
680
+ } catch (error) {
681
+ process.stdout.write(`spec-env hotfix land: ${error.message}.\n`)
682
+ return
683
+ }
684
+
685
+ if (plan.blocked) {
686
+ process.stdout.write(`spec-env hotfix land: blocked — ${plan.reason}.\n`)
687
+ return
688
+ }
689
+ if (plan.noop) {
690
+ process.stdout.write(`spec-env hotfix land: ${plan.reason}.\n`)
691
+ return
692
+ }
693
+
694
+ const out = []
695
+ out.push(`spec-env hotfix land: ${spec.folder}`)
696
+ out.push('')
697
+ out.push(` base tag: ${spec.baseRef}`)
698
+ out.push(` branch: ${spec.branch}`)
699
+ out.push(` prod tag: ${plan.prodTag} (created locally — push to deploy)`)
700
+ for (const t of plan.targets) {
701
+ if (t.kind === 'extra') out.push(` target: ${t.base} -> ${t.tag}`)
702
+ if (t.kind === 'main') out.push(` next rel: cherry-pick onto ${t.base}`)
703
+ }
704
+ out.push('')
705
+ out.push(' run these (abort a cherry-pick on conflict, resolve, then re-run):')
706
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
707
+ out.push('')
708
+ out.push(` then push the deploy tag yourself: git push origin ${plan.prodTag}`)
709
+ process.stdout.write(out.join('\n') + '\n')
710
+ }
711
+
411
712
  // Print the resolved identity/coordinates for a single spec.
412
713
  function specEnvResolve(dir, config, specArg) {
413
714
  if (!specArg) {
@@ -588,15 +889,287 @@ async function specEnvConnect(dir, config, specArg) {
588
889
 
589
890
  // Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
590
891
  // message when the feature isn't enabled (no specs/.core/env.config.json).
892
+ // Run a mutating git command; return { ok, err } (stderr captured, not swallowed).
893
+ function runGit(cwd, args) {
894
+ try {
895
+ execFileSync('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
896
+ return { ok: true, err: '' }
897
+ } catch (error) {
898
+ const err = (error.stderr && error.stderr.toString().trim()) || error.message
899
+ return { ok: false, err }
900
+ }
901
+ }
902
+
903
+ // Lockfiles/manifests whose change means a dev server needs a restart, not HMR.
904
+ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$/
905
+
906
+ // Live overlay: test a spec on the already-running instance by checking its
907
+ // branch out in the primary checkout, so the running dev server hot-reloads the
908
+ // feature. The branch that's checked out IS the lock (assertPrimaryOnMain); the
909
+ // receipt is advisory metadata. `status` is read-only; `take` performs the switch
910
+ // (release/abort land in a later phase).
911
+ async function specEnvLive(dir, config, positional) {
912
+ const action = positional[0] || 'status'
913
+ switch (action) {
914
+ case 'status':
915
+ specEnvLiveStatus(dir, config)
916
+ break
917
+ case 'take':
918
+ await specEnvLiveTake(dir, config, positional[1])
919
+ break
920
+ case 'release':
921
+ await specEnvLiveRelease(dir, config, positional[1])
922
+ break
923
+ case 'abort':
924
+ await specEnvLiveAbort(dir, config)
925
+ break
926
+ default:
927
+ process.stdout.write('Usage: skitterspec spec-env live <take|release|abort|status> [spec]\n')
928
+ }
929
+ }
930
+
931
+ // Resolve a spec, offering its worktree as a fallback search dir — a spec authored
932
+ // on its own branch may not exist in the primary checkout's specs/**.
933
+ function resolveSpecWithWorktree(dir, config, specArg) {
934
+ const { slug } = splitPrefix(path.basename(specArg))
935
+ const { repo, repoSlug } = repoInfo(dir)
936
+ const wtTokens = { repo, repoSlug, slug }
937
+ const worktreeGuess = path.resolve(
938
+ dir,
939
+ expandTokens(config.worktree.root, wtTokens),
940
+ expandTokens(config.worktree.folderPattern, wtTokens),
941
+ )
942
+ return resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
943
+ }
944
+
945
+ // Take the running instance: rebase the spec's branch onto base, free it from its
946
+ // worktree, and check it out in the primary checkout so the dev server reloads it.
947
+ async function specEnvLiveTake(dir, config, specArg) {
948
+ if (!specArg) {
949
+ process.stdout.write('Usage: skitterspec spec-env live take <spec>\n')
950
+ return
951
+ }
952
+
953
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
954
+
955
+ // Probe the primary checkout's git state (IO stays here; the planner is pure).
956
+ const primaryGit = gitReader(dir)
957
+ const primary = assertPrimaryOnMain(config, primaryGit)
958
+ const base = resolveBaseBranch(config, primaryGit)
959
+ const status = primaryGit(['status', '--porcelain'])
960
+ const clean = status !== null && status.length === 0
961
+ const worktreeExists = fs.existsSync(spec.worktreePath)
962
+ const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
963
+
964
+ // Diff base...branch to spot migration / dependency changes (best-effort).
965
+ const changed = primaryGit(['diff', '--name-only', `${base}...${spec.branch}`])
966
+ const files = changed ? changed.split('\n').filter(Boolean) : []
967
+ const depsChanged = files.some((f) => DEPS_RE.test(f))
968
+
969
+ // Verify-only: probe the declared canonical (frontPort) ports. None declared →
970
+ // no health gate (serverUp = null); the switch proceeds with a warning.
971
+ const canonicalPorts = config.dev.map((d) => d.frontPort).filter((p) => typeof p === 'number')
972
+ let serverUp = null
973
+ if (canonicalPorts.length) {
974
+ const up = await portsInUse(canonicalPorts, config.proxy.host)
975
+ serverUp = up.length === canonicalPorts.length
976
+ }
977
+
978
+ const plan = planTake(spec, config, {
979
+ primary,
980
+ primaryPath: dir,
981
+ clean,
982
+ worktreeExists,
983
+ base,
984
+ baseMainCommit,
985
+ serverUp,
986
+ canonicalPorts,
987
+ migrationsHit: migrationsHit(files, config.live.migrations),
988
+ depsChanged,
989
+ holder: primaryGit(['config', 'user.name']) || 'unknown',
990
+ heldSince: new Date().toISOString(),
991
+ })
992
+
993
+ if (plan.blocked) {
994
+ process.stdout.write(`spec-env live take: blocked — ${plan.reason}.\n`)
995
+ return
996
+ }
997
+
998
+ // Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
999
+ const reb = runGit(spec.worktreePath, ['rebase', base])
1000
+ if (!reb.ok) {
1001
+ runGit(spec.worktreePath, ['rebase', '--abort'])
1002
+ process.stdout.write(
1003
+ `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
1004
+ `resolve them in ${spec.worktreePath}, then retry.\n`,
1005
+ )
1006
+ return
1007
+ }
1008
+ const det = runGit(spec.worktreePath, ['switch', '--detach'])
1009
+ if (!det.ok) {
1010
+ process.stdout.write(`spec-env live take: could not detach the worktree — ${det.err}\n`)
1011
+ return
1012
+ }
1013
+ const co = runGit(dir, ['checkout', spec.branch])
1014
+ if (!co.ok) {
1015
+ // Roll the detach back so the worktree keeps its branch.
1016
+ runGit(spec.worktreePath, ['switch', spec.branch])
1017
+ process.stdout.write(
1018
+ `spec-env live take: could not check out ${spec.branch} in the primary ` +
1019
+ `checkout — ${co.err}\n`,
1020
+ )
1021
+ return
1022
+ }
1023
+ const receipt = writeReceipt(dir, config, plan.receipt)
1024
+
1025
+ const out = [`spec-env live take: ${spec.folder} is live on the primary checkout`]
1026
+ out.push('')
1027
+ out.push(` primary: now on ${receipt.branch} (was ${base} @ ${receipt.baseMainCommit.slice(0, 7)})`)
1028
+ out.push(` worktree: ${spec.worktreePath} (detached — branch handed to the primary checkout)`)
1029
+ for (const w of plan.warnings) out.push(` ! ${w}`)
1030
+ out.push('')
1031
+ out.push(' Test at your canonical URL; release with: /spec-live main')
1032
+ process.stdout.write(out.join('\n') + '\n')
1033
+ }
1034
+
1035
+ // Release the running instance: hand the primary checkout back to base and
1036
+ // re-isolate the spec's branch into its worktree. With no spec arg, the live spec
1037
+ // is read from the receipt (this is what `/spec-live main` runs).
1038
+ async function specEnvLiveRelease(dir, config, specArg) {
1039
+ const receipt = readReceipt(dir, config)
1040
+ const target = specArg || (receipt && receipt.spec)
1041
+ if (!target) {
1042
+ const primaryGit = gitReader(dir)
1043
+ const primary = assertPrimaryOnMain(config, primaryGit)
1044
+ process.stdout.write(
1045
+ primary.onBase
1046
+ ? `spec-env live release: nothing is live — the primary checkout is on ${primary.baseBranch}.\n`
1047
+ : `spec-env live release: no receipt, but the primary checkout is on ${primary.branch} — ` +
1048
+ 'use `spec-env live abort` to recover.\n',
1049
+ )
1050
+ return
1051
+ }
1052
+
1053
+ const spec = resolveSpecWithWorktree(dir, config, target)
1054
+ const primaryGit = gitReader(dir)
1055
+ const primary = assertPrimaryOnMain(config, primaryGit)
1056
+ const base = resolveBaseBranch(config, primaryGit)
1057
+ const status = primaryGit(['status', '--porcelain'])
1058
+ const clean = status !== null && status.length === 0
1059
+ const worktreeExists = fs.existsSync(spec.worktreePath)
1060
+
1061
+ const plan = planRelease(spec, config, {
1062
+ primary,
1063
+ primaryPath: dir,
1064
+ base,
1065
+ clean,
1066
+ worktreeExists,
1067
+ })
1068
+
1069
+ if (plan.noop) {
1070
+ process.stdout.write(`spec-env live release: ${plan.reason}.\n`)
1071
+ return
1072
+ }
1073
+ if (plan.blocked) {
1074
+ process.stdout.write(`spec-env live release: blocked — ${plan.reason}.\n`)
1075
+ return
1076
+ }
1077
+
1078
+ const co = runGit(dir, ['checkout', base])
1079
+ if (!co.ok) {
1080
+ process.stdout.write(`spec-env live release: could not check out ${base} — ${co.err}\n`)
1081
+ return
1082
+ }
1083
+ if (worktreeExists) runGit(spec.worktreePath, ['switch', spec.branch])
1084
+ clearReceipt(dir, config)
1085
+
1086
+ process.stdout.write(
1087
+ `spec-env live release: ${spec.folder} released — primary back on ${base}, ` +
1088
+ `${spec.branch} re-isolated to its worktree.\n`,
1089
+ )
1090
+ }
1091
+
1092
+ // Crash recovery: force the primary checkout back to base from the receipt and
1093
+ // re-isolate, without discarding uncommitted work (it refuses on a dirty tree).
1094
+ async function specEnvLiveAbort(dir, config) {
1095
+ const receipt = readReceipt(dir, config)
1096
+ const primaryGit = gitReader(dir)
1097
+ const primary = assertPrimaryOnMain(config, primaryGit)
1098
+ const base = resolveBaseBranch(config, primaryGit)
1099
+ const status = primaryGit(['status', '--porcelain'])
1100
+ const clean = status !== null && status.length === 0
1101
+
1102
+ // Resolve the worktree from the receipt (best-effort — may be gone/unresolvable).
1103
+ let worktreePath = null
1104
+ if (receipt) {
1105
+ try {
1106
+ worktreePath = resolveSpecWithWorktree(dir, config, receipt.spec).worktreePath
1107
+ } catch {
1108
+ worktreePath = null
1109
+ }
1110
+ }
1111
+ const worktreeExists = worktreePath !== null && fs.existsSync(worktreePath)
1112
+
1113
+ const plan = planAbort(config, {
1114
+ receipt,
1115
+ primary,
1116
+ primaryPath: dir,
1117
+ base,
1118
+ clean,
1119
+ worktreeExists,
1120
+ worktreePath,
1121
+ })
1122
+
1123
+ if (plan.noop) {
1124
+ process.stdout.write(`spec-env live abort: ${plan.reason}.\n`)
1125
+ return
1126
+ }
1127
+ if (plan.blocked) {
1128
+ process.stdout.write(`spec-env live abort: blocked — ${plan.reason}.\n`)
1129
+ return
1130
+ }
1131
+
1132
+ if (!primary.onBase) {
1133
+ const co = runGit(dir, ['checkout', base])
1134
+ if (!co.ok) {
1135
+ process.stdout.write(`spec-env live abort: could not check out ${base} — ${co.err}\n`)
1136
+ return
1137
+ }
1138
+ }
1139
+ if (worktreeExists) runGit(worktreePath, ['switch', plan.branch])
1140
+ clearReceipt(dir, config)
1141
+
1142
+ process.stdout.write(
1143
+ `spec-env live abort: recovered — primary back on ${base}` +
1144
+ (worktreeExists ? `, ${plan.branch} re-isolated to its worktree` : '') +
1145
+ '.\n',
1146
+ )
1147
+ }
1148
+
1149
+ function specEnvLiveStatus(dir, config) {
1150
+ const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
1151
+ const receipt = readReceipt(dir, config)
1152
+ const state = onBase
1153
+ ? 'on base — free'
1154
+ : `feature in control — not on ${baseBranch}`
1155
+ process.stdout.write(
1156
+ 'spec-env live:\n' +
1157
+ ` primary: ${branch || '(detached)'} (${state})\n` +
1158
+ ` receipt: ${summarizeReceipt(receipt)}\n`,
1159
+ )
1160
+ }
1161
+
591
1162
  async function specEnv(rest) {
592
1163
  const [sub, ...args] = rest
593
1164
  let dir = process.cwd()
594
1165
  const positional = []
595
- const flags = { keepVolumes: false, force: false }
1166
+ const flags = { keepVolumes: false, force: false, also: [], olderThanDays: null }
596
1167
  for (let i = 0; i < args.length; i++) {
597
1168
  if (args[i] === '--dir') dir = path.resolve(args[++i])
598
1169
  else if (args[i] === '--keep-volumes') flags.keepVolumes = true
599
1170
  else if (args[i] === '--force') flags.force = true
1171
+ else if (args[i] === '--also') flags.also.push(args[++i])
1172
+ else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
600
1173
  else positional.push(args[i])
601
1174
  }
602
1175
  dir = path.resolve(dir)
@@ -620,6 +1193,9 @@ async function specEnv(rest) {
620
1193
  case 'down':
621
1194
  specEnvDown(dir, config, positional[0], flags)
622
1195
  break
1196
+ case 'prune':
1197
+ specEnvPrune(dir, config, flags)
1198
+ break
623
1199
  case 'dev':
624
1200
  await specEnvDev(dir, config, positional)
625
1201
  break
@@ -629,15 +1205,21 @@ async function specEnv(rest) {
629
1205
  case 'integrate':
630
1206
  specEnvIntegrate(dir, config, positional[0])
631
1207
  break
1208
+ case 'hotfix':
1209
+ specEnvHotfix(dir, config, positional, flags)
1210
+ break
632
1211
  case 'status':
633
1212
  specEnvStatus(dir, config)
634
1213
  break
635
1214
  case 'resolve':
636
1215
  specEnvResolve(dir, config, positional[0])
637
1216
  break
1217
+ case 'live':
1218
+ await specEnvLive(dir, config, positional)
1219
+ break
638
1220
  default:
639
1221
  process.stdout.write(
640
- 'Usage: skitterspec spec-env <up|down|dev|connect|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
1222
+ 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n',
641
1223
  )
642
1224
  }
643
1225
  }
@@ -664,21 +1246,54 @@ async function run(argv) {
664
1246
 
665
1247
  switch (cmd) {
666
1248
  case 'init': {
667
- // Isolation defaults OFF; a flag or an interactive "yes" opts in.
668
- let isolation = opts.isolation === true
669
-
670
1249
  const interactive = Boolean(process.stdin.isTTY) && !opts.yes
671
- if (interactive) {
672
- const { promptSetup } = require('./prompts.js')
673
- const result = await promptSetup({ isolationSeed: isolation })
674
- isolation = result.isolation
1250
+
1251
+ // Already set up? Route to resync / reset / leave instead of a silent
1252
+ // create-missing (safer-init). A fresh repo falls straight through.
1253
+ if (isExistingSetup(dir)) {
1254
+ let action
1255
+ if (opts.reset) {
1256
+ if (!opts.yes) {
1257
+ process.stdout.write('init: --reset overwrites managed files — re-run with --yes. Left unchanged.\n')
1258
+ break
1259
+ }
1260
+ action = 'reset'
1261
+ } else if (opts.resync) action = 'resync'
1262
+ else if (opts.force) action = 'resync' // --force resyncs, clobbering customized
1263
+ else if (interactive) {
1264
+ const { promptExistingSetup } = require('./prompts.js')
1265
+ action = await promptExistingSetup()
1266
+ } else action = 'create-missing' // non-interactive default: add missing, never clobber
1267
+
1268
+ if (action === 'leave') {
1269
+ process.stdout.write('init: existing setup left unchanged.\n')
1270
+ break
1271
+ }
1272
+ if (action === 'reset') {
1273
+ reset(dir, { claudeMd: opts.claudeMd })
1274
+ break
1275
+ }
1276
+ if (action === 'resync') {
1277
+ resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
1278
+ break
1279
+ }
1280
+ // action === 'create-missing' → fall through to a normal (skip-existing) init.
675
1281
  }
676
1282
 
1283
+ // Fresh repo (or create-missing): isolation defaults OFF; a flag or an
1284
+ // interactive "yes" opts in. Only prompt for isolation on a fresh repo.
1285
+ let isolation = opts.isolation === true
1286
+ if (interactive && !isExistingSetup(dir)) {
1287
+ const { promptSetup } = require('./prompts.js')
1288
+ isolation = (await promptSetup({ isolationSeed: isolation })).isolation
1289
+ }
677
1290
  await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
678
1291
  break
679
1292
  }
680
1293
  case 'update':
681
- await init({ dir, force: true, claudeMd: opts.claudeMd, mode: 'update' })
1294
+ // `update` is a resync refresh managed files, keep customized ones
1295
+ // (--force to overwrite). Leaves specs/ and live .core config alone.
1296
+ resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
682
1297
  await cleanupReleaseTooling(dir, opts)
683
1298
  break
684
1299
  default: