@skitterbyte/skitterspec-linear 5.0.0 → 7.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/assets/core/env.config.md +39 -2
- package/assets/rules/spec-planning.md +37 -16
- package/assets/skills/spec/SKILL.md +27 -0
- package/assets/skills/spec-bug/SKILL.md +20 -0
- package/assets/skills/spec-cancel/SKILL.md +5 -0
- package/assets/skills/spec-complete/SKILL.md +65 -11
- package/assets/skills/spec-go/SKILL.md +18 -3
- package/assets/skills/spec-hotfix/SKILL.md +181 -0
- package/assets/skills/spec-live/SKILL.md +4 -1
- package/assets/skills/spec-review/SKILL.md +10 -3
- package/assets/skills/spec-to-main/SKILL.md +99 -0
- package/package.json +1 -1
- package/src/cli.js +333 -6
- package/src/env/config.js +18 -0
- package/src/env/hotfix.js +133 -0
- package/src/env/live.js +12 -3
- package/src/env/provision.js +5 -1
- package/src/env/prune.js +119 -0
- package/src/env/resolve.js +28 -4
- package/src/env/teardown.js +20 -13
|
@@ -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-linear",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
|
|
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))
|
|
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
|
-
|
|
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
|
|