@skitterbyte/skitterspec 12.0.0 → 14.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.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: spec-to-main
3
+ description: Land an in-progress spec's branch onto main WITHOUT finishing it — rebase + fast-forward so the work reaches main (to run it in CI / a shared test env), while the worktree stays and the spec stays In Progress. Repeatable — land again as you commit more. Targets a spec by name (arg) or the spec in context. Use when the user says "/spec-to-main", "land this on main but keep going", "I need this on main to run tests before finishing", or "merge to main without completing the spec".
4
+ ---
5
+
6
+ # /spec-to-main — land the branch on main, keep the spec open
7
+
8
+ The **intermediate** landing. `/spec-complete` also lands the branch, but then
9
+ verifies every phase, flips the status to Complete, `git mv`s the spec to
10
+ `complete/`, and tears the environment down. **`/spec-to-main` stops after the
11
+ land**: the worktree stays, the spec stays `In Progress`, and you can land again
12
+ as you add commits.
13
+
14
+ Use it when a later phase can only be done *after* the current work is on `main` —
15
+ e.g. it needs to run in CI, a deploy pipeline, or a shared test environment that
16
+ builds from `main`. Land what you have, run that step, then come back and finish
17
+ the remaining phases with `/spec-go` and eventually `/spec-complete`.
18
+
19
+ It reuses the **same engine** as `/spec-complete`'s landing (`spec-env integrate`
20
+ — rebase + fast-forward), so it produces identical linear history. Because a
21
+ fast-forward leaves `base == branch`, the operation is **idempotent and
22
+ repeatable**: new commits put the branch ahead of base again, and you can run
23
+ `/spec-to-main` as many times as you like.
24
+
25
+ ## 0. Preconditions — when this applies
26
+
27
+ - **Isolation must be on** (`specs/.core/env.config.json` exists **and** the spec
28
+ is on a worktree provisioned by `/spec-go`). If isolation is absent, there is
29
+ nothing to land — the spec is authored directly on `main` already. Say so and
30
+ stop.
31
+ - **Feature / Bug specs only.** A **Hotfix** (`Type: Hotfix`) is built on a
32
+ release *tag* and cannot fast-forward onto `main` — refuse it and point the user
33
+ at `/spec-complete` (it lands a hotfix via tag + cherry-pick). Check the header
34
+ `> **Type:**` before proceeding.
35
+
36
+ ## 1. Identify the target spec
37
+
38
+ - Use the name/path argument if given, else the spec **in context**. If unclear,
39
+ ask which spec.
40
+ - Locate its folder under `specs/in-progress/`. Entry point is `00-overview.md`;
41
+ confirm `> **Status:**` is `In Progress` and `> **Type:**` is `Feature` or
42
+ `Bug`.
43
+
44
+ ## 2. Require a clean worktree
45
+
46
+ The land rebases the branch — it refuses a dirty tree. If the worktree has
47
+ uncommitted changes, offer `/commit` and **stop**; don't auto-commit.
48
+
49
+ **If the spec is live** (you took the running instance with `/spec-live`):
50
+ `integrate` is live-aware — it ends the live session first (releases the branch
51
+ back to base, re-isolates it into its worktree, clears the receipt), then prints
52
+ the landing plan. Commit any live fixes to the branch first; it refuses if the
53
+ primary checkout is dirty, or if a *different* spec holds it (release that one with
54
+ `/spec-live main`).
55
+
56
+ ## 3. Tests must be green before landing
57
+
58
+ Don't push red to `main`. Run the project's typecheck and test commands **in the
59
+ worktree**; the suite must be **green**. For a **Bug** spec, confirm the
60
+ originally-failing test now passes. If anything is red, stop and report — landing
61
+ broken code onto `main` defeats the purpose.
62
+
63
+ (Note this is the *worktree* suite. The whole point of this skill is often to run
64
+ a *further* check that only exists on `main` / in CI — that one runs **after** the
65
+ land, in step 5.)
66
+
67
+ ## 4. Land — rebase + fast-forward
68
+
69
+ Run `skitterspec spec-env integrate <name>` and run the printed commands **in
70
+ order**:
71
+
72
+ - `git -C <worktree> rebase <base>` — replay the branch onto base.
73
+ - `git -C <mainRepoPath> merge --ff-only <branch>` — fast-forward base.
74
+
75
+ On a **rebase conflict** (non-zero exit), run `git -C <worktree> rebase --abort`,
76
+ relay the conflict, and **stop** — leave the resolution to the user; change
77
+ nothing else.
78
+
79
+ On a **no-op** ("already landed on `<base>` — nothing to integrate"), just say so
80
+ and continue — the branch has no commits base doesn't already have.
81
+
82
+ ## 5. Re-test on base, then report
83
+
84
+ - Run the project's test command **from the primary checkout** — base must be
85
+ **green** after the fast-forward.
86
+ - Add a **Changelog** entry to `00-overview.md` recording the intermediate land
87
+ and *why*, e.g.
88
+ `- <YYYY-MM-DD> — Landed intermediate work onto <base> to <run CI / deploy to
89
+ test env / …>; spec stays In Progress.`
90
+ - Do **NOT**: add a State-log row (status doesn't change), flip any phase/status
91
+ to Complete, `git mv` the spec, or tear down the worktree/stack. **The spec
92
+ stays `In Progress` and the worktree stays put.**
93
+ - Report: the base branch, the fast-forward result, and the green base test. It
94
+ **never pushes** — mention the user can `git push` the base branch themselves to
95
+ trigger CI / the shared env.
96
+ - Point the way forward: `/spec-go` to continue the remaining phases (you'll keep
97
+ committing on the same branch and can `/spec-to-main` again), and `/spec-complete`
98
+ when every phase is genuinely done — it will land the final commits, finalise,
99
+ and tear down.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "12.0.0",
3
+ "version": "14.0.0",
4
4
  "description": "Spec-driven development for Claude Code — a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -39,7 +39,9 @@ const {
39
39
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
40
40
  const { planUp } = require('./env/provision.js')
41
41
  const { planDown } = require('./env/teardown.js')
42
+ const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
42
43
  const { planIntegrate } = require('./env/integrate.js')
44
+ const { planHotfixLand } = require('./env/hotfix.js')
43
45
  const { planDev } = require('./env/dev.js')
44
46
  const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
45
47
  const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
@@ -60,10 +62,12 @@ Usage:
60
62
  specs/.core/env.config.json). Subcommands:
61
63
  up <spec> plan a worktree + Docker stack + opener
62
64
  down <spec> tear down (guards; --keep-volumes, --force)
65
+ prune reap orphaned test-DB volumes (--older-than <days>)
63
66
  dev up <spec> start host dev servers on the spec's ports
64
67
  dev down <spec> stop the spec's host dev servers
65
68
  connect <spec> expose a spec on the canonical ports (main = off)
66
69
  integrate <spec> plan rebase + fast-forward onto the base branch
70
+ hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
67
71
  status list provisioned specs + port blocks
68
72
  resolve <spec> print resolved slug/type/branch/paths
69
73
  skitterspec --help Show this help
@@ -181,6 +185,19 @@ function specEnvUp(dir, config, specArg) {
181
185
  }
182
186
  const spec = resolveSpec(specArg, dir, config)
183
187
 
188
+ // Live-safe: if this spec is already live on the primary checkout (its branch was
189
+ // branch-switched in by `live take`), a `git worktree add` would fail — the branch
190
+ // is checked out there. Point the operator at the primary checkout rather than
191
+ // emit a plan that can't run (see spec feat-live-spec-flow).
192
+ const primaryUp = assertPrimaryOnMain(config, gitReader(dir))
193
+ if (!primaryUp.onBase && primaryUp.branch === spec.branch) {
194
+ process.stdout.write(
195
+ `spec-env up: ${spec.folder} is live in the primary checkout — work there directly ` +
196
+ '(its branch is checked out), or run `/spec-live main` first to re-isolate its worktree.\n',
197
+ )
198
+ return
199
+ }
200
+
184
201
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
185
202
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
186
203
  // every provision for teammates who only cloned and ran /spec-go.
@@ -271,7 +288,9 @@ function gitReader(cwd) {
271
288
  // the resolved integration branch; `merged` is true when HEAD is already an
272
289
  // ancestor of it (fully landed), which lets teardown skip the unpushed guard.
273
290
  function worktreeGitState(worktreePath, base) {
274
- if (!fs.existsSync(worktreePath)) return { dirty: false, unpushed: false, merged: true }
291
+ if (!fs.existsSync(worktreePath)) {
292
+ return { dirty: false, unpushed: false, merged: true, reachableFromTag: false }
293
+ }
275
294
  const git = gitReader(worktreePath)
276
295
 
277
296
  const status = git(['status', '--porcelain'])
@@ -293,7 +312,13 @@ function worktreeGitState(worktreePath, base) {
293
312
  // exits 0 when true; gitReader maps a non-zero exit to null.
294
313
  const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
295
314
 
296
- return { dirty, unpushed, merged }
315
+ // reachableFromTag = HEAD is captured by a tag (the deploy tag from `hotfix
316
+ // land`). A hotfix branch is never merged into base, so this is what tells
317
+ // teardown its commits are safely recoverable.
318
+ const pointing = git(['tag', '--points-at', 'HEAD'])
319
+ const reachableFromTag = pointing !== null && pointing.length > 0
320
+
321
+ return { dirty, unpushed, merged, reachableFromTag }
297
322
  }
298
323
 
299
324
  // A deterministic-enough compact timestamp for backup filenames (CLI-only; the
@@ -358,6 +383,166 @@ function specEnvDown(dir, config, specArg, flags) {
358
383
  process.stdout.write(out.join('\n') + '\n')
359
384
  }
360
385
 
386
+ // --- prune: reap orphaned per-spec test-DB volumes -------------------------
387
+
388
+ // Live Docker volumes in the repo namespace (`{repoSlug}_…`). Returns
389
+ // { ok, names }: ok:false means docker is unavailable / errored (non-fatal — the
390
+ // caller reports and skips). The `name=` filter is a substring match, so we
391
+ // re-check the prefix in the pure planner.
392
+ function listRepoVolumes(repoSlug) {
393
+ try {
394
+ const out = execFileSync(
395
+ 'docker',
396
+ ['volume', 'ls', '--format', '{{.Name}}', '--filter', `name=${repoSlug}_`],
397
+ { stdio: ['ignore', 'pipe', 'pipe'] },
398
+ )
399
+ .toString()
400
+ .trim()
401
+ const names = out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : []
402
+ return { ok: true, names }
403
+ } catch (error) {
404
+ const err = (error.stderr && error.stderr.toString().trim()) || error.message
405
+ return { ok: false, names: [], err }
406
+ }
407
+ }
408
+
409
+ // Map orphan-candidate volume names → creation epoch-ms via `docker volume
410
+ // inspect`. Unknown/unparseable timestamps stay null (the planner keeps them).
411
+ function volumeCreatedAt(names) {
412
+ const byName = new Map()
413
+ if (!names.length) return byName
414
+ try {
415
+ const out = execFileSync(
416
+ 'docker',
417
+ ['volume', 'inspect', '--format', '{{.Name}}\t{{.CreatedAt}}', ...names],
418
+ { stdio: ['ignore', 'pipe', 'pipe'] },
419
+ )
420
+ .toString()
421
+ .trim()
422
+ for (const line of out.split('\n')) {
423
+ const [name, created] = line.split('\t')
424
+ const ms = created ? Date.parse(created.trim()) : NaN
425
+ if (name) byName.set(name.trim(), Number.isNaN(ms) ? null : ms)
426
+ }
427
+ } catch {
428
+ // Inspect failed wholesale → treat every candidate as unknown-age (kept).
429
+ }
430
+ return byName
431
+ }
432
+
433
+ // Absolute paths of every checkout git knows about (primary + all worktrees).
434
+ function liveWorktreePaths(dir) {
435
+ const out = gitReader(dir)(['worktree', 'list', '--porcelain'])
436
+ const paths = new Set()
437
+ if (out == null) return paths
438
+ for (const line of out.split('\n')) {
439
+ if (line.startsWith('worktree ')) {
440
+ paths.add(path.resolve(line.slice('worktree '.length).trim()))
441
+ }
442
+ }
443
+ return paths
444
+ }
445
+
446
+ // Every spec folder name found under specs/* across the given checkout roots.
447
+ // An in-progress spec lives on its *worktree branch*, not the primary checkout,
448
+ // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
449
+ function collectSpecFolders(roots) {
450
+ const folders = new Set()
451
+ for (const root of roots) {
452
+ for (const bucket of ['backlog', 'in-progress', 'complete', 'cancelled']) {
453
+ let entries
454
+ try {
455
+ entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
456
+ } catch {
457
+ continue
458
+ }
459
+ for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
460
+ }
461
+ }
462
+ return folders
463
+ }
464
+
465
+ // Resolve every spec folder (found in the primary checkout OR any worktree) to
466
+ // { folder, slug, worktreePath }. `searchDirs` lets resolveSpec locate a spec
467
+ // that was authored on its branch and never committed to the primary checkout.
468
+ function allSpecs(dir, config, worktreePaths) {
469
+ const searchDirs = [...worktreePaths]
470
+ const specs = []
471
+ for (const folder of collectSpecFolders([dir, ...searchDirs])) {
472
+ try {
473
+ const spec = resolveSpec(folder, dir, config, { searchDirs })
474
+ specs.push({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
475
+ } catch {
476
+ // Unresolvable folder (not a real spec) — skip.
477
+ }
478
+ }
479
+ return specs
480
+ }
481
+
482
+ // Prune: reconcile namespace volumes against specs that still have a worktree and
483
+ // print the `docker volume rm` commands for the orphans. Liveness keys off the
484
+ // worktree, NOT the registry (a declined teardown leaves a stale slot behind), so
485
+ // this correctly reaps those and frees their stale slots. Destructive removal is
486
+ // executed by the caller (skill) after confirmation — the CLI only plans + writes
487
+ // the registry, mirroring `spec-env down`.
488
+ function specEnvPrune(dir, config, flags) {
489
+ const { repoSlug } = repoInfo(dir)
490
+
491
+ const vols = listRepoVolumes(repoSlug)
492
+ if (!vols.ok) {
493
+ process.stdout.write(
494
+ `spec-env prune: could not list docker volumes — ${vols.err || 'docker unavailable'}.\n` +
495
+ 'Is Docker running? Nothing pruned.\n',
496
+ )
497
+ return
498
+ }
499
+
500
+ const worktrees = liveWorktreePaths(dir)
501
+ const specs = allSpecs(dir, config, worktrees)
502
+ const liveSlugs = liveSlugsForSpecs(specs, worktrees)
503
+
504
+ const olderThanDays =
505
+ flags && Number.isFinite(flags.olderThanDays) ? flags.olderThanDays : null
506
+ let volumes = vols.names
507
+ let now = null
508
+ if (olderThanDays != null) {
509
+ const createdAt = volumeCreatedAt(vols.names)
510
+ volumes = vols.names.map((name) => ({ name, createdAt: createdAt.get(name) ?? null }))
511
+ now = Date.now()
512
+ }
513
+
514
+ const plan = planPrune(volumes, liveSlugs, { repoSlug, olderThanDays, now })
515
+
516
+ if (!plan.orphans.length) {
517
+ process.stdout.write(
518
+ `spec-env prune: no orphaned volumes in ${repoSlug}_* ` +
519
+ `(${vols.names.length} namespace volume(s), ${liveSlugs.size} live spec(s) protected).\n`,
520
+ )
521
+ return
522
+ }
523
+
524
+ // Reconcile the registry: free the slot of any spec whose volume we're reaping.
525
+ const registry = readRegistry(dir, config)
526
+ const { registry: nextRegistry, freed } = reconcileRegistry(registry, plan.orphans, repoSlug)
527
+ if (freed.length) writeRegistry(dir, config, nextRegistry)
528
+
529
+ const ageNote = olderThanDays != null ? ` older than ${olderThanDays}d` : ''
530
+ const out = []
531
+ out.push(
532
+ `spec-env prune: ${plan.orphans.length} orphaned volume(s)${ageNote} ` +
533
+ `(${liveSlugs.size} live spec(s) protected)`,
534
+ )
535
+ out.push('')
536
+ out.push(' orphans:')
537
+ for (const o of plan.orphans) out.push(` ${o.name}`)
538
+ if (freed.length) out.push(` slots freed: ${freed.join(', ')}`)
539
+ out.push(' backup: none (prune does not back up — orphans have no running DB)')
540
+ out.push('')
541
+ out.push(' run these:')
542
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
543
+ process.stdout.write(out.join('\n') + '\n')
544
+ }
545
+
361
546
  // Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
362
547
  // Queries git for the facts, prints the plan / block / no-op. The /spec-complete
363
548
  // skill executes the printed commands (and aborts a conflicting rebase).
@@ -395,6 +580,33 @@ function specEnvIntegrate(dir, config, specArg) {
395
580
  )
396
581
  return
397
582
  }
583
+ // Work-loss guard — runs BEFORE the destructive `checkout base` that ends the
584
+ // live session. Ending the session must leave landable work behind; if it
585
+ // wouldn't, abort loudly instead of silently finalizing the spec with nothing
586
+ // landed (see spec feat-live-spec-flow).
587
+ if (!fs.existsSync(spec.worktreePath)) {
588
+ process.stdout.write(
589
+ `spec-env integrate: blocked — ${spec.folder} is live but has no worktree to land ` +
590
+ `from. Re-isolate it with \`skitterspec spec-env up ${spec.folder}\`, then re-run.\n`,
591
+ )
592
+ return
593
+ }
594
+ const liveWtGit = gitReader(spec.worktreePath)
595
+ if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
596
+ // Detached worktree HEAD: any commits ahead of the branch ref (e.g. made by a
597
+ // non-live-aware /spec-go) would be abandoned by the re-isolate `switch` below.
598
+ const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
599
+ const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
600
+ if (stranded !== null && Number(stranded) > 0) {
601
+ process.stdout.write(
602
+ `spec-env integrate: blocked — ${stranded} commit(s) are stranded on the detached ` +
603
+ `HEAD of ${spec.worktreePath} (at ${head}), ahead of ${spec.branch}; re-isolating ` +
604
+ `would abandon them. Recover with \`git -C ${spec.worktreePath} branch <tmp> ${head}\`, ` +
605
+ `reconcile onto ${spec.branch}, then re-run.\n`,
606
+ )
607
+ return
608
+ }
609
+ }
398
610
  const co = runGit(dir, ['checkout', base])
399
611
  if (!co.ok) {
400
612
  process.stdout.write(`spec-env integrate: could not check out ${base} — ${co.err}\n`)
@@ -450,6 +662,93 @@ function specEnvIntegrate(dir, config, specArg) {
450
662
  process.stdout.write(out.join('\n') + '\n')
451
663
  }
452
664
 
665
+ // Land a hotfix: tag the branch with the patch-bumped base tag (the prod deploy
666
+ // tag), cherry-pick the fix onto any extra base tags (test/demo lines) and onto
667
+ // the base branch for the next release. Queries git for the facts, prints the plan
668
+ // / block / no-op. The /spec-complete skill runs the printed commands (aborting a
669
+ // cherry-pick on conflict). NEVER pushes — pushing the deploy tag is the operator's.
670
+ function specEnvHotfix(dir, config, positional, flags) {
671
+ const action = positional[0]
672
+ const specArg = positional[1]
673
+ if (action !== 'land' || !specArg) {
674
+ process.stdout.write('Usage: skitterspec spec-env hotfix land <spec> [--also <tag>]...\n')
675
+ return
676
+ }
677
+
678
+ // A hotfix may be authored entirely on its branch, so fall back to its worktree.
679
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
680
+ if (spec.type !== 'hotfix') {
681
+ process.stdout.write(
682
+ `spec-env hotfix land: ${spec.folder} is not a hotfix — needs Type: Hotfix / a hotfix- prefix.\n`,
683
+ )
684
+ return
685
+ }
686
+ if (!fs.existsSync(spec.worktreePath)) {
687
+ process.stdout.write(`spec-env hotfix land: ${spec.folder} has no worktree — nothing to land.\n`)
688
+ return
689
+ }
690
+
691
+ const base = resolveBaseBranch(config, gitReader(dir))
692
+ const wtGit = gitReader(spec.worktreePath)
693
+ const status = wtGit(['status', '--porcelain'])
694
+ const dirty = status !== null && status.length > 0
695
+ const ahead = wtGit(['rev-list', '--count', `${spec.baseRef}..HEAD`])
696
+ const aheadOfBase = ahead !== null && Number(ahead) > 0
697
+ const tagList = wtGit(['tag', '--list'])
698
+ const existingTags = tagList ? tagList.split('\n').map((s) => s.trim()).filter(Boolean) : []
699
+
700
+ // Extra targets: --also flags first, then any config defaults; drop blanks, the
701
+ // base tag itself, and duplicates.
702
+ const seen = new Set()
703
+ const extraTargets = [...(flags.also || []), ...(config.hotfix.targets || [])].filter((t) => {
704
+ if (!t || t === spec.baseRef || seen.has(t)) return false
705
+ seen.add(t)
706
+ return true
707
+ })
708
+
709
+ let plan
710
+ try {
711
+ plan = planHotfixLand(spec, config, {
712
+ worktreeState: { dirty },
713
+ aheadOfBase,
714
+ fixRange: `${spec.baseRef}..${spec.branch}`,
715
+ mainRepoPath: dir,
716
+ base,
717
+ extraTargets,
718
+ existingTags,
719
+ })
720
+ } catch (error) {
721
+ process.stdout.write(`spec-env hotfix land: ${error.message}.\n`)
722
+ return
723
+ }
724
+
725
+ if (plan.blocked) {
726
+ process.stdout.write(`spec-env hotfix land: blocked — ${plan.reason}.\n`)
727
+ return
728
+ }
729
+ if (plan.noop) {
730
+ process.stdout.write(`spec-env hotfix land: ${plan.reason}.\n`)
731
+ return
732
+ }
733
+
734
+ const out = []
735
+ out.push(`spec-env hotfix land: ${spec.folder}`)
736
+ out.push('')
737
+ out.push(` base tag: ${spec.baseRef}`)
738
+ out.push(` branch: ${spec.branch}`)
739
+ out.push(` prod tag: ${plan.prodTag} (created locally — push to deploy)`)
740
+ for (const t of plan.targets) {
741
+ if (t.kind === 'extra') out.push(` target: ${t.base} -> ${t.tag}`)
742
+ if (t.kind === 'main') out.push(` next rel: cherry-pick onto ${t.base}`)
743
+ }
744
+ out.push('')
745
+ out.push(' run these (abort a cherry-pick on conflict, resolve, then re-run):')
746
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
747
+ out.push('')
748
+ out.push(` then push the deploy tag yourself: git push origin ${plan.prodTag}`)
749
+ process.stdout.write(out.join('\n') + '\n')
750
+ }
751
+
453
752
  // Print the resolved identity/coordinates for a single spec.
454
753
  function specEnvResolve(dir, config, specArg) {
455
754
  if (!specArg) {
@@ -653,7 +952,7 @@ async function specEnvLive(dir, config, positional) {
653
952
  const action = positional[0] || 'status'
654
953
  switch (action) {
655
954
  case 'status':
656
- specEnvLiveStatus(dir, config)
955
+ specEnvLiveStatus(dir, config, positional[1])
657
956
  break
658
957
  case 'take':
659
958
  await specEnvLiveTake(dir, config, positional[1])
@@ -887,8 +1186,28 @@ async function specEnvLiveAbort(dir, config) {
887
1186
  )
888
1187
  }
889
1188
 
890
- function specEnvLiveStatus(dir, config) {
1189
+ function specEnvLiveStatus(dir, config, specArg) {
891
1190
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
1191
+
1192
+ // Per-spec query (`live status <spec>`): a clear yes/no verdict the /spec-go
1193
+ // skill branches on to decide whether to skip worktree provisioning and work in
1194
+ // the primary checkout. The stable `live: yes|no` line is the machine seam.
1195
+ if (specArg) {
1196
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1197
+ const live = !onBase && branch === spec.branch
1198
+ process.stdout.write(
1199
+ `spec-env live status: ${spec.folder}\n` +
1200
+ ` spec: ${spec.folder} (branch ${spec.branch})\n` +
1201
+ ` primary: ${branch || '(detached)'}\n` +
1202
+ ` live: ${
1203
+ live
1204
+ ? `yes — ${spec.folder} holds the primary checkout; work there`
1205
+ : `no — primary is on ${branch || '(detached)'}`
1206
+ }\n`,
1207
+ )
1208
+ return
1209
+ }
1210
+
892
1211
  const receipt = readReceipt(dir, config)
893
1212
  const state = onBase
894
1213
  ? 'on base — free'
@@ -904,11 +1223,13 @@ async function specEnv(rest) {
904
1223
  const [sub, ...args] = rest
905
1224
  let dir = process.cwd()
906
1225
  const positional = []
907
- const flags = { keepVolumes: false, force: false }
1226
+ const flags = { keepVolumes: false, force: false, also: [], olderThanDays: null }
908
1227
  for (let i = 0; i < args.length; i++) {
909
1228
  if (args[i] === '--dir') dir = path.resolve(args[++i])
910
1229
  else if (args[i] === '--keep-volumes') flags.keepVolumes = true
911
1230
  else if (args[i] === '--force') flags.force = true
1231
+ else if (args[i] === '--also') flags.also.push(args[++i])
1232
+ else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
912
1233
  else positional.push(args[i])
913
1234
  }
914
1235
  dir = path.resolve(dir)
@@ -932,6 +1253,9 @@ async function specEnv(rest) {
932
1253
  case 'down':
933
1254
  specEnvDown(dir, config, positional[0], flags)
934
1255
  break
1256
+ case 'prune':
1257
+ specEnvPrune(dir, config, flags)
1258
+ break
935
1259
  case 'dev':
936
1260
  await specEnvDev(dir, config, positional)
937
1261
  break
@@ -941,6 +1265,9 @@ async function specEnv(rest) {
941
1265
  case 'integrate':
942
1266
  specEnvIntegrate(dir, config, positional[0])
943
1267
  break
1268
+ case 'hotfix':
1269
+ specEnvHotfix(dir, config, positional, flags)
1270
+ break
944
1271
  case 'status':
945
1272
  specEnvStatus(dir, config)
946
1273
  break
@@ -952,7 +1279,7 @@ async function specEnv(rest) {
952
1279
  break
953
1280
  default:
954
1281
  process.stdout.write(
955
- 'Usage: skitterspec spec-env <up|down|dev|connect|integrate|live|status|resolve> [spec] [--keep-volumes] [--force]\n',
1282
+ 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n',
956
1283
  )
957
1284
  }
958
1285
  }
package/src/env/config.js CHANGED
@@ -31,6 +31,9 @@
31
31
  * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
32
32
  * live: { migrations: [ "glob", ... ] } // migration globs → `live take`
33
33
  * // refuses a branch that changes them (code-only v1)
34
+ * hotfix: { bump, cherryPickMain, targets } // `hotfix land`: patch-bump the
35
+ * // deploy tag, also cherry-pick onto the base branch (main), and
36
+ * // onto any extra base tags in `targets` (test/demo lines)
34
37
  * }
35
38
  */
36
39
 
@@ -80,6 +83,12 @@ const DEFAULT_CONFIG = Object.freeze({
80
83
  // migration files; a branch that changes any of them is treated as stateful and
81
84
  // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
82
85
  live: Object.freeze({ migrations: Object.freeze([]) }),
86
+ // Hotfix landing (`spec-env hotfix land`). `bump` is the version-bump strategy
87
+ // for the new deploy tag (only "patch" today). `cherryPickMain` also cherry-picks
88
+ // the fix onto the base branch for the next release (default true). `targets` is
89
+ // an optional default list of extra base tags to also patch (test/demo lines);
90
+ // `--also <tag>` on the command adds more at run time. Default: patch, main, none.
91
+ hotfix: Object.freeze({ bump: 'patch', cherryPickMain: true, targets: Object.freeze([]) }),
83
92
  })
84
93
 
85
94
  function isObject(value) {
@@ -101,6 +110,7 @@ function defaults() {
101
110
  baseBranch: DEFAULT_CONFIG.baseBranch,
102
111
  guards: { ...DEFAULT_CONFIG.guards },
103
112
  live: { migrations: [] },
113
+ hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
104
114
  }
105
115
  }
106
116
 
@@ -248,6 +258,14 @@ function mergeConfig(base, parsed) {
248
258
  base.live.migrations = normalizeFileList(parsed.live.migrations)
249
259
  }
250
260
 
261
+ if (isObject(parsed.hotfix)) {
262
+ assign(base.hotfix, parsed.hotfix, 'bump', 'string')
263
+ assign(base.hotfix, parsed.hotfix, 'cherryPickMain', 'boolean')
264
+ if (Array.isArray(parsed.hotfix.targets)) {
265
+ base.hotfix.targets = normalizeFileList(parsed.hotfix.targets)
266
+ }
267
+ }
268
+
251
269
  return base
252
270
  }
253
271