@skitterbyte/skitterspec-linear 5.0.0 → 6.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
@@ -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
@@ -271,7 +275,9 @@ function gitReader(cwd) {
271
275
  // the resolved integration branch; `merged` is true when HEAD is already an
272
276
  // ancestor of it (fully landed), which lets teardown skip the unpushed guard.
273
277
  function worktreeGitState(worktreePath, base) {
274
- 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
+ }
275
281
  const git = gitReader(worktreePath)
276
282
 
277
283
  const status = git(['status', '--porcelain'])
@@ -293,7 +299,13 @@ function worktreeGitState(worktreePath, base) {
293
299
  // exits 0 when true; gitReader maps a non-zero exit to null.
294
300
  const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
295
301
 
296
- 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 }
297
309
  }
298
310
 
299
311
  // A deterministic-enough compact timestamp for backup filenames (CLI-only; the
@@ -358,6 +370,166 @@ function specEnvDown(dir, config, specArg, flags) {
358
370
  process.stdout.write(out.join('\n') + '\n')
359
371
  }
360
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
+
361
533
  // Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
362
534
  // Queries git for the facts, prints the plan / block / no-op. The /spec-complete
363
535
  // skill executes the printed commands (and aborts a conflicting rebase).
@@ -450,6 +622,93 @@ function specEnvIntegrate(dir, config, specArg) {
450
622
  process.stdout.write(out.join('\n') + '\n')
451
623
  }
452
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
+
453
712
  // Print the resolved identity/coordinates for a single spec.
454
713
  function specEnvResolve(dir, config, specArg) {
455
714
  if (!specArg) {
@@ -904,11 +1163,13 @@ async function specEnv(rest) {
904
1163
  const [sub, ...args] = rest
905
1164
  let dir = process.cwd()
906
1165
  const positional = []
907
- const flags = { keepVolumes: false, force: false }
1166
+ const flags = { keepVolumes: false, force: false, also: [], olderThanDays: null }
908
1167
  for (let i = 0; i < args.length; i++) {
909
1168
  if (args[i] === '--dir') dir = path.resolve(args[++i])
910
1169
  else if (args[i] === '--keep-volumes') flags.keepVolumes = true
911
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])
912
1173
  else positional.push(args[i])
913
1174
  }
914
1175
  dir = path.resolve(dir)
@@ -932,6 +1193,9 @@ async function specEnv(rest) {
932
1193
  case 'down':
933
1194
  specEnvDown(dir, config, positional[0], flags)
934
1195
  break
1196
+ case 'prune':
1197
+ specEnvPrune(dir, config, flags)
1198
+ break
935
1199
  case 'dev':
936
1200
  await specEnvDev(dir, config, positional)
937
1201
  break
@@ -941,6 +1205,9 @@ async function specEnv(rest) {
941
1205
  case 'integrate':
942
1206
  specEnvIntegrate(dir, config, positional[0])
943
1207
  break
1208
+ case 'hotfix':
1209
+ specEnvHotfix(dir, config, positional, flags)
1210
+ break
944
1211
  case 'status':
945
1212
  specEnvStatus(dir, config)
946
1213
  break
@@ -952,7 +1219,7 @@ async function specEnv(rest) {
952
1219
  break
953
1220
  default:
954
1221
  process.stdout.write(
955
- 'Usage: skitterspec spec-env <up|down|dev|connect|integrate|live|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',
956
1223
  )
957
1224
  }
958
1225
  }
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
 
@@ -0,0 +1,133 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure landing planner for `spec-env hotfix land`.
5
+ *
6
+ * A hotfix is built on an old release tag (its `Base version`), so it can't be
7
+ * fast-forwarded onto `main` like an ordinary spec (that's `integrate.js`).
8
+ * Instead `planHotfixLand` emits the exact side-effect-free, **never-pushing**
9
+ * commands to:
10
+ * 1. tag the hotfix branch head with the patch-bumped base tag (the prod deploy
11
+ * tag — the branch head already is baseRef + the fix);
12
+ * 2. for each extra target tag, cherry-pick the fix onto a throwaway worktree at
13
+ * that tag and re-tag it with its own patch bump (test/demo release lines);
14
+ * 3. cherry-pick the fix onto the base branch (main) for the next release.
15
+ *
16
+ * It performs no side effects — the caller (the CLI) probes git for `dirty` /
17
+ * `aheadOfBase` / `existingTags` / `mainRepoPath` and supplies them, keeping this
18
+ * deterministic and unit-testable with no live git. Conflict handling lives in the
19
+ * skill (run a cherry-pick, abort on non-zero exit, hand back), mirroring
20
+ * `integrate.js` — the planner never reasons about conflicts.
21
+ */
22
+
23
+ const path = require('node:path')
24
+
25
+ /**
26
+ * Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
27
+ * non-digit prefix (e.g. `v` → `v33.16.5`) and drops any pre-release/build suffix
28
+ * (`-rc1`, `+build`). Throws on a tag with no `MAJOR.MINOR.PATCH` core.
29
+ */
30
+ function bumpPatch(tag) {
31
+ const m = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:.*)$/.exec(String(tag || '').trim())
32
+ if (!m) {
33
+ throw new Error(
34
+ `hotfix: cannot bump a patch version from tag "${tag}" — need <prefix>MAJOR.MINOR.PATCH`,
35
+ )
36
+ }
37
+ const [, prefix, major, minor, patch] = m
38
+ return `${prefix}${major}.${minor}.${Number(patch) + 1}`
39
+ }
40
+
41
+ // A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
42
+ function tagSlug(tag) {
43
+ return String(tag)
44
+ .replace(/[^a-zA-Z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ }
47
+
48
+ /**
49
+ * @param {object} spec resolved hotfix spec: { slug, branch, worktreePath, baseRef, ... }
50
+ * @param {object} config normalised env config (reads `config.hotfix`).
51
+ * @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
52
+ * base, extraTargets: string[], existingTags: string[] }
53
+ * @returns {object} { blocked, noop, reason, commands, prodTag, targets, branch, base }
54
+ */
55
+ function planHotfixLand(spec, config, ctx) {
56
+ const c = ctx || {}
57
+ const branch = spec.branch
58
+ const baseRef = spec.baseRef
59
+ const hotfixCfg = (config && config.hotfix) || { cherryPickMain: true, targets: [] }
60
+ const result = {
61
+ blocked: false,
62
+ noop: false,
63
+ reason: null,
64
+ commands: [],
65
+ prodTag: null,
66
+ targets: [],
67
+ branch,
68
+ base: c.base,
69
+ }
70
+ const block = (reason) => ({ ...result, blocked: true, reason })
71
+
72
+ // A hotfix must carry the tag it forked from — everything below bumps from it.
73
+ if (!baseRef) {
74
+ return block('spec has no Base version — not a hotfix, or the header is missing')
75
+ }
76
+ // The completion edits (status flip, git mv) must be committed before landing.
77
+ if (c.worktreeState && c.worktreeState.dirty) {
78
+ return block('worktree has uncommitted changes — commit the completion first')
79
+ }
80
+ // Nothing on the branch beyond the base tag → nothing to land.
81
+ if (!c.aheadOfBase) {
82
+ return { ...result, noop: true, reason: `no commits on ${branch} beyond ${baseRef} — nothing to land` }
83
+ }
84
+
85
+ // Track tags we'd create so we never plan a collision (with an existing tag, or
86
+ // between the prod tag and a target tag).
87
+ const taken = new Set(c.existingTags || [])
88
+ const fixRange = c.fixRange
89
+ const commands = []
90
+ const targets = []
91
+
92
+ // 1. Prod line: tag the hotfix branch head (already baseRef + fix). No push.
93
+ const prodTag = bumpPatch(baseRef)
94
+ if (taken.has(prodTag)) {
95
+ return block(`tag ${prodTag} already exists — bump the base version or delete the stale tag`)
96
+ }
97
+ taken.add(prodTag)
98
+ commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
99
+ targets.push({ kind: 'prod', base: baseRef, tag: prodTag })
100
+
101
+ // 2. Extra targets: cherry-pick the fix onto a throwaway worktree at each tag,
102
+ // re-tag with its own patch bump, then remove the worktree + temp branch (the
103
+ // commits survive under the new tag, so `-D` is safe for the throwaway branch).
104
+ const wtRoot = path.dirname(spec.worktreePath)
105
+ for (const t of c.extraTargets || []) {
106
+ const tag = bumpPatch(t)
107
+ if (taken.has(tag)) {
108
+ return block(`tag ${tag} (for target ${t}) already exists — resolve it before landing`)
109
+ }
110
+ taken.add(tag)
111
+ const slug = `${spec.slug}-onto-${tagSlug(t)}`
112
+ const tmpBranch = `hotfix/${slug}`
113
+ const tmpPath = path.join(wtRoot, slug)
114
+ commands.push(
115
+ `git worktree add ${tmpPath} -b ${tmpBranch} ${t}`,
116
+ `git -C ${tmpPath} cherry-pick ${fixRange}`,
117
+ `git -C ${tmpPath} tag ${tag}`,
118
+ `git worktree remove ${tmpPath}`,
119
+ `git branch -D ${tmpBranch}`,
120
+ )
121
+ targets.push({ kind: 'extra', base: t, tag, worktreePath: tmpPath })
122
+ }
123
+
124
+ // 3. Cherry-pick the fix onto the base branch (main) for the next release.
125
+ if (hotfixCfg.cherryPickMain !== false) {
126
+ commands.push(`git -C ${c.mainRepoPath} cherry-pick ${fixRange}`)
127
+ targets.push({ kind: 'main', base: c.base })
128
+ }
129
+
130
+ return { ...result, commands, prodTag, targets, fixRange }
131
+ }
132
+
133
+ module.exports = { bumpPatch, tagSlug, planHotfixLand }
package/src/env/live.js CHANGED
@@ -179,21 +179,30 @@ function planTake(spec, config, ctx) {
179
179
  if (!c.worktreeExists) {
180
180
  return block(`${spec.folder} has no worktree — run \`/spec-go ${spec.folder}\` first`)
181
181
  }
182
- // 4. v1 is code-only: refuse a stateful spec (Stack: worktree + docker)…
182
+ // 4. A hotfix is built on an old release tag; checking its branch out under the
183
+ // running dev server risks schema/DB drift breaking the shared instance.
184
+ // Always refuse, regardless of Stack — test it in isolation via /spec-connect.
185
+ if (spec.type === 'hotfix') {
186
+ return block(
187
+ `${spec.folder} is a hotfix (built on an old release tag) — live overlay ` +
188
+ 'could break the running instance; use `/spec-connect` to test it in isolation',
189
+ )
190
+ }
191
+ // 5. v1 is code-only: refuse a stateful spec (Stack: worktree + docker)…
183
192
  if (spec.stack === 'docker') {
184
193
  return block(
185
194
  `${spec.folder} is stateful (Stack: worktree + docker) — live overlay is ` +
186
195
  'code-only; use `/spec-connect` for a Docker-backed spec',
187
196
  )
188
197
  }
189
- // 5. …and refuse a branch that changes migrations (would mutate the shared DB).
198
+ // 6. …and refuse a branch that changes migrations (would mutate the shared DB).
190
199
  if (c.migrationsHit) {
191
200
  return block(
192
201
  `${spec.folder}'s branch changes migrations — live overlay is code-only; ` +
193
202
  'use `/spec-connect`',
194
203
  )
195
204
  }
196
- // 6. Verify-only: a dev server must be listening to hot-reload the switch.
205
+ // 7. Verify-only: a dev server must be listening to hot-reload the switch.
197
206
  if (c.serverUp === false) {
198
207
  return block(
199
208
  `no dev server listening on canonical port(s) ${(c.canonicalPorts || []).join(', ')} — ` +
@@ -94,10 +94,14 @@ function planUp(spec, alloc, config) {
94
94
 
95
95
  const commands = []
96
96
  // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
97
+ // A hotfix forks its fresh branch from a release tag (`spec.baseRef`, e.g.
98
+ // `v33.16.4`) instead of base HEAD — so the fix is built on the exact commit
99
+ // line prod runs. Attaching an existing branch ignores baseRef (already forked).
100
+ const forkPoint = !attached && spec.baseRef ? ` ${spec.baseRef}` : ''
97
101
  commands.push(
98
102
  attached
99
103
  ? `git worktree add ${spec.worktreePath} ${spec.branch}`
100
- : `git worktree add ${spec.worktreePath} -b ${spec.branch}`,
104
+ : `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
101
105
  )
102
106
  if (wantsDocker) {
103
107
  commands.push(`docker compose --project-name ${spec.projectName} up -d`)