@skitterbyte/skitterspec-linear 10.7.0 → 11.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/MIGRATION.md +36 -0
- package/README.md +3 -3
- package/assets/claude-md-section.md +13 -51
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +1 -1
- package/assets/core/env.config.json.example +4 -1
- package/assets/core/env.config.md +26 -6
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +46 -4
- package/assets/rules/spec-planning.md +30 -10
- package/assets/skills/spec/SKILL.md +15 -10
- package/assets/skills/spec-bug/SKILL.md +43 -9
- package/assets/skills/spec-cancel/SKILL.md +12 -0
- package/assets/skills/spec-complete/SKILL.md +26 -5
- package/assets/skills/spec-hotfix/SKILL.md +42 -8
- package/assets/skills/spec-init/SKILL.md +16 -6
- package/assets/skills/spec-linear-setup/SKILL.md +1 -1
- package/assets/skills/spec-next/SKILL.md +141 -0
- package/assets/skills/spec-push/SKILL.md +2 -2
- package/assets/skills/spec-review/SKILL.md +5 -5
- package/assets/skills/spec-start/SKILL.md +146 -0
- package/assets/skills/spec-status/SKILL.md +2 -2
- package/assets/skills/spec-sync/SKILL.md +9 -1
- package/assets/skills/spec-to-main/SKILL.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +234 -20
- package/src/env/config.js +24 -1
- package/src/env/integrate.js +61 -1
- package/src/env/live.js +27 -3
- package/src/env/provision.js +58 -1
- package/src/env/teardown.js +41 -3
- package/src/init.js +21 -12
- package/src/prompts.js +31 -3
- package/src/vendor/linear/cli-sync.js +55 -4
- package/src/vendor/linear/config.js +70 -3
- package/src/vendor/linear/released.js +65 -5
- package/assets/skills/spec-go/SKILL.md +0 -233
|
@@ -15,7 +15,7 @@ as you add commits.
|
|
|
15
15
|
Use it when a later phase can only be done *after* the current work is on `main` —
|
|
16
16
|
e.g. it needs to run in CI, a deploy pipeline, or a shared test environment that
|
|
17
17
|
builds from `main`. Land what you have, run that step, then come back and finish
|
|
18
|
-
the remaining phases with `/spec-
|
|
18
|
+
the remaining phases with `/spec-next` and eventually `/spec-complete`.
|
|
19
19
|
|
|
20
20
|
It reuses the **same engine** as `/spec-complete`'s landing (`spec-env integrate`
|
|
21
21
|
— rebase + fast-forward), so it produces identical linear history. Because a
|
|
@@ -26,9 +26,11 @@ repeatable**: new commits put the branch ahead of base again, and you can run
|
|
|
26
26
|
## 0. Preconditions — when this applies
|
|
27
27
|
|
|
28
28
|
- **Isolation must be on** (`specs/.core/env.config.json` exists **and** the spec
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
stop.
|
|
29
|
+
was provisioned by `/spec-start` — a worktree, or its branch in the checkout under
|
|
30
|
+
`mode: checkout`). If isolation is absent, there is nothing to land — the spec
|
|
31
|
+
is authored directly on `main` already. Say so and stop. `spec-env integrate`
|
|
32
|
+
reads `mode` and emits the matching plan; in checkout mode it rebases in place,
|
|
33
|
+
switches to base and fast-forwards, all in the one repo.
|
|
32
34
|
- **Feature / Bug specs only.** A **Hotfix** (`Type: Hotfix`) is built on a
|
|
33
35
|
release *tag* and cannot fast-forward onto `main` — refuse it and point the user
|
|
34
36
|
at `/spec-complete` (it lands a hotfix via tag + cherry-pick). Check the header
|
|
@@ -94,7 +96,7 @@ and continue — the branch has no commits base doesn't already have.
|
|
|
94
96
|
- Report: the base branch, the fast-forward result, and the green base test. It
|
|
95
97
|
**never pushes** — mention the user can `git push` the base branch themselves to
|
|
96
98
|
trigger CI / the shared env.
|
|
97
|
-
- Point the way forward: `/spec-
|
|
99
|
+
- Point the way forward: `/spec-next` to continue the remaining phases (you'll keep
|
|
98
100
|
committing on the same branch and can `/spec-to-main` again), and `/spec-complete`
|
|
99
101
|
when every phase is genuinely done — it will land the final commits, finalise,
|
|
100
102
|
and tear down.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.js
CHANGED
|
@@ -37,10 +37,10 @@ const {
|
|
|
37
37
|
planAbort,
|
|
38
38
|
} = require('./env/live.js')
|
|
39
39
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
40
|
-
const { planUp } = require('./env/provision.js')
|
|
41
|
-
const { planDown } = require('./env/teardown.js')
|
|
40
|
+
const { planUp, planCheckoutUp } = require('./env/provision.js')
|
|
41
|
+
const { planDown, planDownCheckout } = require('./env/teardown.js')
|
|
42
42
|
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
43
|
-
const { planIntegrate } = require('./env/integrate.js')
|
|
43
|
+
const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
|
|
44
44
|
const { planHotfixLand } = require('./env/hotfix.js')
|
|
45
45
|
const { planDev } = require('./env/dev.js')
|
|
46
46
|
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
@@ -233,9 +233,63 @@ function specEnvStatus(dir, config) {
|
|
|
233
233
|
// the /spec-env skill executes (git worktree add, docker compose up, .env,
|
|
234
234
|
// opener). This creates no worktree and starts no stack — the caller runs the
|
|
235
235
|
// printed commands. Keep the output's verb honest about that.
|
|
236
|
+
// `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
|
|
237
|
+
// planner, and prints the plan or the refusal.
|
|
238
|
+
function specEnvUpCheckout(dir, config, spec) {
|
|
239
|
+
const git = gitReader(dir)
|
|
240
|
+
const primary = assertPrimaryOnMain(config, git)
|
|
241
|
+
const base = resolveBaseBranch(config, git)
|
|
242
|
+
const status = git(['status', '--porcelain'])
|
|
243
|
+
|
|
244
|
+
const plan = planCheckoutUp(
|
|
245
|
+
spec,
|
|
246
|
+
{
|
|
247
|
+
current: primary.branch,
|
|
248
|
+
base,
|
|
249
|
+
onBase: primary.onBase,
|
|
250
|
+
// A null status means git could not be read at all. Treated as NOT clean:
|
|
251
|
+
// the harmless outcome of being wrong is a refusal the operator can act
|
|
252
|
+
// on, and the harmful one is carrying their work onto a new branch.
|
|
253
|
+
clean: status !== null && status.length === 0,
|
|
254
|
+
branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
|
|
255
|
+
checkoutPath: dir,
|
|
256
|
+
},
|
|
257
|
+
config,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if (plan.blocked) {
|
|
261
|
+
process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const out = [
|
|
266
|
+
`spec-env up: ${spec.folder} ` +
|
|
267
|
+
(plan.attached ? '(already on this branch — nothing to do)' : '(plan — nothing created yet)'),
|
|
268
|
+
'',
|
|
269
|
+
` mode: checkout (branch built in the primary checkout)`,
|
|
270
|
+
` checkout: ${plan.checkoutPath}`,
|
|
271
|
+
` branch: ${plan.branch}`,
|
|
272
|
+
' stack: checkout-only (no worktree, no docker, no port block)',
|
|
273
|
+
]
|
|
274
|
+
if (plan.commands.length) {
|
|
275
|
+
out.push('')
|
|
276
|
+
out.push(' to provision, run:')
|
|
277
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
278
|
+
}
|
|
279
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
280
|
+
}
|
|
281
|
+
|
|
236
282
|
function specEnvUp(dir, config, specArg) {
|
|
237
283
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
238
284
|
|
|
285
|
+
// Checkout mode: the branch is built in the primary checkout, so none of the
|
|
286
|
+
// worktree machinery below applies — no slot, no trust entry, no bootstrap and
|
|
287
|
+
// no opener. Handled first precisely so none of that runs by accident.
|
|
288
|
+
if (config.mode === 'checkout') {
|
|
289
|
+
specEnvUpCheckout(dir, config, spec)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
239
293
|
// Live-safe: if this spec is already live on the primary checkout (its branch was
|
|
240
294
|
// branch-switched in by `live take`), a `git worktree add` would fail — the branch
|
|
241
295
|
// is checked out there. Point the operator at the primary checkout rather than
|
|
@@ -251,7 +305,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
251
305
|
|
|
252
306
|
// Trust the shared worktree root so edits into the freshly-provisioned worktree
|
|
253
307
|
// don't prompt. One absolute entry (the root) covers every spec; self-heals on
|
|
254
|
-
// every provision for teammates who only cloned and ran /spec-
|
|
308
|
+
// every provision for teammates who only cloned and ran /spec-start.
|
|
255
309
|
const worktreeRootAbs = path.dirname(spec.worktreePath)
|
|
256
310
|
const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
|
|
257
311
|
|
|
@@ -295,6 +349,10 @@ function specEnvUp(dir, config, specArg) {
|
|
|
295
349
|
} else {
|
|
296
350
|
out.push(' stack: worktree-only (no docker, no port block)')
|
|
297
351
|
}
|
|
352
|
+
// The loader falls back silently on an unrecognised `mode`, so this line is
|
|
353
|
+
// the operator's only evidence of which mode actually resolved — print it
|
|
354
|
+
// whenever it was set explicitly, right or wrong.
|
|
355
|
+
out.push(' mode: worktree (each spec gets its own checkout)')
|
|
298
356
|
if (trust.reason === 'malformed') {
|
|
299
357
|
out.push(
|
|
300
358
|
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
@@ -429,12 +487,45 @@ function compactTimestamp() {
|
|
|
429
487
|
// when the spec was never provisioned / already torn down. Deliberately does NOT
|
|
430
488
|
// touch the trusted worktree root in .claude/settings.local.json — that entry is
|
|
431
489
|
// the shared parent of every spec's worktree and harmless when empty; removing it
|
|
432
|
-
// would just re-prompt on the next /spec-
|
|
490
|
+
// would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
|
|
433
491
|
function specEnvDown(dir, config, specArg, flags) {
|
|
434
492
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
435
493
|
|
|
436
494
|
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
437
495
|
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
496
|
+
// Checkout mode first: there is no worktree and no registry slot by design, so
|
|
497
|
+
// the "not provisioned" guard below would report a live spec as absent.
|
|
498
|
+
if (config.mode === 'checkout') {
|
|
499
|
+
const dgit = gitReader(dir)
|
|
500
|
+
const dbase = resolveBaseBranch(config, dgit)
|
|
501
|
+
if (dgit(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) === null) {
|
|
502
|
+
process.stdout.write(`spec-env down: ${spec.folder} has no branch — nothing to do.\n`)
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
const dst = dgit(['status', '--porcelain'])
|
|
506
|
+
const contains = dgit(['branch', '--contains', spec.branch, '--list', dbase])
|
|
507
|
+
const dplan = planDownCheckout(spec, config, flags, {
|
|
508
|
+
dirty: dst === null || dst.length > 0,
|
|
509
|
+
landed: Boolean(contains && contains.trim()),
|
|
510
|
+
onBranch: dgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
511
|
+
base: dbase,
|
|
512
|
+
checkoutPath: dir,
|
|
513
|
+
})
|
|
514
|
+
if (dplan.blocked) {
|
|
515
|
+
process.stdout.write(
|
|
516
|
+
`spec-env down: blocked — ${dplan.reason}.\n` +
|
|
517
|
+
'Re-run with --force to tear down anyway (deletes the branch).\n',
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const dout = [`spec-env down: ${spec.folder}`, '',
|
|
522
|
+
' mode: checkout (no worktree, no slot, no volumes)',
|
|
523
|
+
` branch: ${dplan.branch}`, '', ' run these:']
|
|
524
|
+
for (const cmd of dplan.commands) dout.push(` ${cmd}`)
|
|
525
|
+
process.stdout.write(dout.join('\n') + '\n')
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
|
|
438
529
|
const registry = readRegistry(dir, config)
|
|
439
530
|
const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
|
|
440
531
|
if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
|
|
@@ -566,7 +657,7 @@ function liveWorktreePaths(dir) {
|
|
|
566
657
|
* bucket disappears the moment it empties.
|
|
567
658
|
*
|
|
568
659
|
* Three outcomes, never two: resolved → that spec; several candidates and no cwd
|
|
569
|
-
* hint → throw, listing them; none → throw, pointing at /spec-
|
|
660
|
+
* hint → throw, listing them; none → throw, pointing at /spec-start. *Cannot tell*
|
|
570
661
|
* never becomes a guess.
|
|
571
662
|
*
|
|
572
663
|
* BLIND SPOT: a spec taken live with `/spec-live` has had its branch moved into
|
|
@@ -603,7 +694,7 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
|
|
|
603
694
|
if (provisioned.length === 0) {
|
|
604
695
|
throw new Error(
|
|
605
696
|
'no spec given, and no spec has a worktree — name one explicitly, or run ' +
|
|
606
|
-
'/spec-
|
|
697
|
+
'/spec-start to provision it.',
|
|
607
698
|
)
|
|
608
699
|
}
|
|
609
700
|
throw new Error(
|
|
@@ -764,6 +855,39 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
764
855
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
765
856
|
const base = resolveBaseBranch(config, gitReader(dir))
|
|
766
857
|
|
|
858
|
+
// Checkout mode short-circuits everything below. The live handling in
|
|
859
|
+
// particular reads "primary is on the spec's branch" as a live session — true
|
|
860
|
+
// in worktree mode, and simply where the branch LIVES in checkout mode, so it
|
|
861
|
+
// would refuse to land a spec sitting exactly where it belongs.
|
|
862
|
+
if (config.mode === 'checkout') {
|
|
863
|
+
const cgit = gitReader(dir)
|
|
864
|
+
const cst = cgit(['status', '--porcelain'])
|
|
865
|
+
const cahead = cgit(['rev-list', '--count', `${base}..${spec.branch}`])
|
|
866
|
+
const cplan = planIntegrateCheckout(spec, config, {
|
|
867
|
+
dirty: cst === null || cst.length > 0,
|
|
868
|
+
base,
|
|
869
|
+
aheadOfBase: cahead !== null && Number(cahead) > 0,
|
|
870
|
+
checkoutPath: dir,
|
|
871
|
+
onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
872
|
+
})
|
|
873
|
+
if (cplan.blocked) {
|
|
874
|
+
process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
if (cplan.noop) {
|
|
878
|
+
process.stdout.write(
|
|
879
|
+
`spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
|
|
880
|
+
)
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
const cout = [`spec-env integrate: ${spec.folder}`, '', ' mode: checkout',
|
|
884
|
+
` base: ${base}`, ` branch: ${cplan.branch}`, '',
|
|
885
|
+
' run these (abort the rebase on conflict):']
|
|
886
|
+
for (const cmd of cplan.commands) cout.push(` ${cmd}`)
|
|
887
|
+
process.stdout.write(cout.join('\n') + '\n')
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
767
891
|
// Live-aware: if this spec is live on the primary checkout (branch-switched by
|
|
768
892
|
// `live take`), end the live session first — release back to base, re-isolate the
|
|
769
893
|
// branch, clear the receipt — so the normal rebase→ff plan below applies
|
|
@@ -798,7 +922,7 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
798
922
|
const liveWtGit = gitReader(spec.worktreePath)
|
|
799
923
|
if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
|
|
800
924
|
// Detached worktree HEAD: any commits ahead of the branch ref (e.g. made by a
|
|
801
|
-
//
|
|
925
|
+
// a build that committed in the worktree) would be abandoned by the re-isolate `switch` below.
|
|
802
926
|
const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
|
|
803
927
|
const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
|
|
804
928
|
if (stranded !== null && Number(stranded) > 0) {
|
|
@@ -1050,6 +1174,16 @@ function proxyProcFor(config, routesFileAbs) {
|
|
|
1050
1174
|
// bundled proxy pointing at that spec's warm dev servers. `connect main` stops
|
|
1051
1175
|
// the proxy so the primary checkout owns the canonical ports again.
|
|
1052
1176
|
async function specEnvConnect(dir, config, specArg) {
|
|
1177
|
+
// Both verbs exist to route around the work living somewhere other than the
|
|
1178
|
+
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
1179
|
+
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
1180
|
+
if (config.mode === 'checkout') {
|
|
1181
|
+
process.stdout.write(
|
|
1182
|
+
'spec-env connect: not applicable in checkout mode — the spec is built in the ' +
|
|
1183
|
+
'primary checkout, so your dev server already serves it on the canonical ports.\\n',
|
|
1184
|
+
)
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1053
1187
|
const sdir = stateDirLabel(config)
|
|
1054
1188
|
const abs = (rel) => path.resolve(dir, rel)
|
|
1055
1189
|
const routesFile = `${sdir}/proxy.json`
|
|
@@ -1151,25 +1285,64 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
|
|
|
1151
1285
|
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
1152
1286
|
// (release/abort land in a later phase).
|
|
1153
1287
|
async function specEnvLive(dir, config, positional) {
|
|
1154
|
-
|
|
1288
|
+
// Both verbs exist to route around the work living somewhere other than the
|
|
1289
|
+
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
1290
|
+
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
1291
|
+
if (config.mode === 'checkout') {
|
|
1292
|
+
process.stdout.write(
|
|
1293
|
+
'spec-env live: not applicable in checkout mode — the spec branch is already ' +
|
|
1294
|
+
'checked out here. `mode: checkout` is the permanent form of what live overlay ' +
|
|
1295
|
+
'does temporarily.\\n',
|
|
1296
|
+
)
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1299
|
+
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
1155
1300
|
switch (action) {
|
|
1156
1301
|
case 'status':
|
|
1157
|
-
specEnvLiveStatus(dir, config,
|
|
1302
|
+
specEnvLiveStatus(dir, config, specArg)
|
|
1158
1303
|
break
|
|
1159
1304
|
case 'take':
|
|
1160
|
-
await specEnvLiveTake(dir, config,
|
|
1305
|
+
await specEnvLiveTake(dir, config, specArg)
|
|
1161
1306
|
break
|
|
1162
1307
|
case 'release':
|
|
1163
|
-
await specEnvLiveRelease(dir, config,
|
|
1308
|
+
await specEnvLiveRelease(dir, config, specArg)
|
|
1164
1309
|
break
|
|
1165
1310
|
case 'abort':
|
|
1166
1311
|
await specEnvLiveAbort(dir, config)
|
|
1167
1312
|
break
|
|
1168
1313
|
default:
|
|
1169
|
-
process.stdout.write(
|
|
1314
|
+
process.stdout.write(
|
|
1315
|
+
'Usage: skitterspec spec-env live <spec>|<base branch>|<take|release|abort|status> [spec]\n',
|
|
1316
|
+
)
|
|
1170
1317
|
}
|
|
1171
1318
|
}
|
|
1172
1319
|
|
|
1320
|
+
const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
|
|
1321
|
+
|
|
1322
|
+
// The two front doors every doc names — `/spec-live <spec>` and `/spec-live main`
|
|
1323
|
+
// — translated to verbs. They live here rather than in the command because
|
|
1324
|
+
// `.claude/commands/spec-live.md` relays `$ARGUMENTS` untranslated (it is a
|
|
1325
|
+
// pre-executed script, with no model turn to rewrite them); the skill this
|
|
1326
|
+
// replaced did the translation itself, which is how these forms came to be
|
|
1327
|
+
// documented but unimplemented. `connect` needs no equivalent — its argument was
|
|
1328
|
+
// always spec-shaped (`specArg || 'main'`).
|
|
1329
|
+
//
|
|
1330
|
+
// VERB PRECEDENCE IS DELIBERATE, and so is the order below: the four verbs and
|
|
1331
|
+
// the base branch are matched BEFORE the spec-name fallback, so a spec folder
|
|
1332
|
+
// that happens to be called `status` cannot silently branch-switch the primary
|
|
1333
|
+
// checkout. Such a spec is still reachable — as `live take status`. The literal
|
|
1334
|
+
// `main` is honoured even where the base branch is named something else,
|
|
1335
|
+
// matching `connect main`, so the muscle memory works in either repo.
|
|
1336
|
+
function liveGrammar(dir, config, positional) {
|
|
1337
|
+
const [first, second] = positional
|
|
1338
|
+
if (!first) return { action: 'status', specArg: undefined }
|
|
1339
|
+
if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
|
|
1340
|
+
if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
|
|
1341
|
+
return { action: 'release', specArg: undefined }
|
|
1342
|
+
}
|
|
1343
|
+
return { action: 'take', specArg: first }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1173
1346
|
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
1174
1347
|
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
1175
1348
|
async function specEnvLiveTake(dir, config, specArg) {
|
|
@@ -1182,6 +1355,10 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1182
1355
|
const status = primaryGit(['status', '--porcelain'])
|
|
1183
1356
|
const clean = status !== null && status.length === 0
|
|
1184
1357
|
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
1358
|
+
// The tree the rebase actually runs in. Unreadable → treated as dirty: being
|
|
1359
|
+
// wrong that way costs a message, the other way moves work we could not see.
|
|
1360
|
+
const wtStatus = worktreeExists ? gitReader(spec.worktreePath)(['status', '--porcelain']) : ''
|
|
1361
|
+
const worktreeClean = wtStatus !== null && wtStatus.length === 0
|
|
1185
1362
|
const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
|
|
1186
1363
|
|
|
1187
1364
|
// Diff base...branch to spot migration / dependency changes (best-effort).
|
|
@@ -1201,7 +1378,9 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1201
1378
|
const plan = planTake(spec, config, {
|
|
1202
1379
|
primary,
|
|
1203
1380
|
primaryPath: dir,
|
|
1381
|
+
inFlight: (readReceipt(dir, config) || {}).spec || null,
|
|
1204
1382
|
clean,
|
|
1383
|
+
worktreeClean,
|
|
1205
1384
|
worktreeExists,
|
|
1206
1385
|
base,
|
|
1207
1386
|
baseMainCommit,
|
|
@@ -1221,10 +1400,19 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1221
1400
|
// Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
|
|
1222
1401
|
const reb = runGit(spec.worktreePath, ['rebase', base])
|
|
1223
1402
|
if (!reb.ok) {
|
|
1224
|
-
|
|
1403
|
+
// A rebase fails two ways and they need different answers. It can REFUSE TO
|
|
1404
|
+
// START (unstaged changes, a missing base) — nothing to abort, nothing to
|
|
1405
|
+
// resolve — or start and CONFLICT. Calling both "hit conflicts" sent people
|
|
1406
|
+
// hunting a conflict that did not exist, and `--abort` on a rebase that
|
|
1407
|
+
// never began discarded git's own explanation of what was actually wrong.
|
|
1408
|
+
const started = runGit(spec.worktreePath, ['rebase', '--show-current-patch']).ok
|
|
1409
|
+
if (started) runGit(spec.worktreePath, ['rebase', '--abort'])
|
|
1225
1410
|
process.stdout.write(
|
|
1226
|
-
|
|
1227
|
-
`
|
|
1411
|
+
started
|
|
1412
|
+
? `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
|
|
1413
|
+
`resolve them in ${spec.worktreePath}, then retry.\n`
|
|
1414
|
+
: `spec-env live take: rebase of ${spec.branch} onto ${base} could not start — ` +
|
|
1415
|
+
`git said:\n ${reb.err.split('\n').join('\n ')}\n`,
|
|
1228
1416
|
)
|
|
1229
1417
|
return
|
|
1230
1418
|
}
|
|
@@ -1372,7 +1560,7 @@ async function specEnvLiveAbort(dir, config) {
|
|
|
1372
1560
|
function specEnvLiveStatus(dir, config, specArg) {
|
|
1373
1561
|
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
1374
1562
|
|
|
1375
|
-
// Per-spec query (`live status <spec>`): a clear yes/no verdict
|
|
1563
|
+
// Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
|
|
1376
1564
|
// skill branches on to decide whether to skip worktree provisioning and work in
|
|
1377
1565
|
// the primary checkout. The stable `live: yes|no` line is the machine seam.
|
|
1378
1566
|
if (specArg) {
|
|
@@ -1395,9 +1583,23 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
1395
1583
|
const state = onBase
|
|
1396
1584
|
? 'on base — free'
|
|
1397
1585
|
: `feature in control — not on ${baseBranch}`
|
|
1586
|
+
|
|
1587
|
+
// `in-flight:` is a MACHINE SEAM, like the per-spec `live:` line above, and
|
|
1588
|
+
// `/spec-next` reads it to decide which spec it is allowed to build. It answers
|
|
1589
|
+
// in three states rather than two, because "cannot tell" is real here: a branch
|
|
1590
|
+
// switched by hand carries no receipt, so the spec is unknown even though the
|
|
1591
|
+
// checkout is plainly busy. Reporting that as `none` would invite building the
|
|
1592
|
+
// wrong spec; reporting the branch says what is true and lets the caller stop.
|
|
1593
|
+
const inFlight = onBase
|
|
1594
|
+
? 'none — the workbench is free'
|
|
1595
|
+
: receipt && receipt.spec
|
|
1596
|
+
? `${receipt.spec} (branch ${branch || '(detached)'})`
|
|
1597
|
+
: `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
|
|
1598
|
+
|
|
1398
1599
|
process.stdout.write(
|
|
1399
1600
|
'spec-env live:\n' +
|
|
1400
1601
|
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
1602
|
+
` in-flight: ${inFlight}\n` +
|
|
1401
1603
|
` receipt: ${summarizeReceipt(receipt)}\n`,
|
|
1402
1604
|
)
|
|
1403
1605
|
}
|
|
@@ -1466,7 +1668,9 @@ async function specEnv(rest) {
|
|
|
1466
1668
|
' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
|
|
1467
1669
|
' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
|
|
1468
1670
|
' NOTE connect and live status keep their own meaning for a missing spec:\n' +
|
|
1469
|
-
' connect disconnects (= main), live status reports on the whole repo.\n'
|
|
1671
|
+
' connect disconnects (= main), live status reports on the whole repo.\n' +
|
|
1672
|
+
' connect and live also take a bare spec name: `live <spec>` takes the\n' +
|
|
1673
|
+
' instance, `live main` (or your base branch) hands it back.\n',
|
|
1470
1674
|
)
|
|
1471
1675
|
}
|
|
1472
1676
|
}
|
|
@@ -1530,11 +1734,21 @@ async function run(argv) {
|
|
|
1530
1734
|
// Fresh repo (or create-missing): isolation defaults OFF; a flag or an
|
|
1531
1735
|
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1532
1736
|
let isolation = opts.isolation === true
|
|
1737
|
+
let workspaceMode = 'worktree'
|
|
1533
1738
|
if (interactive && !isExistingSetup(dir)) {
|
|
1534
1739
|
const { promptSetup } = require('./prompts.js')
|
|
1535
|
-
|
|
1740
|
+
const answers = await promptSetup({ isolationSeed: isolation })
|
|
1741
|
+
isolation = answers.isolation
|
|
1742
|
+
workspaceMode = answers.mode
|
|
1536
1743
|
}
|
|
1537
|
-
await init({
|
|
1744
|
+
await init({
|
|
1745
|
+
dir,
|
|
1746
|
+
force: opts.force,
|
|
1747
|
+
claudeMd: opts.claudeMd,
|
|
1748
|
+
mode: 'init',
|
|
1749
|
+
isolation,
|
|
1750
|
+
workspaceMode,
|
|
1751
|
+
})
|
|
1538
1752
|
break
|
|
1539
1753
|
}
|
|
1540
1754
|
case 'update':
|
package/src/env/config.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Shape (see specs/.core/env.config.md for field docs):
|
|
15
15
|
* {
|
|
16
|
+
* mode: "worktree" | "checkout", // where a spec's branch is built —
|
|
17
|
+
* // its own worktree (default), or the primary checkout in place.
|
|
18
|
+
* // NOTE: unrelated to `seedFiles.mode`, which is symlink|copy.
|
|
16
19
|
* worktree: { root, folderPattern },
|
|
17
20
|
* docker: { enabled, composeFile, projectNamePattern, portBase,
|
|
18
21
|
* portsPerSpec, envFile, backupCommand },
|
|
@@ -44,6 +47,16 @@ const { join } = require('node:path')
|
|
|
44
47
|
const CONFIG_FILE = join('specs', '.core', 'env.config.json')
|
|
45
48
|
|
|
46
49
|
const DEFAULT_CONFIG = Object.freeze({
|
|
50
|
+
// Where a spec's branch is built. "worktree" gives every spec its own checkout
|
|
51
|
+
// — parallel specs, `main` left free — at the cost of a terminal session per
|
|
52
|
+
// spec. "checkout" builds the branch in the primary checkout instead: one spec
|
|
53
|
+
// at a time, in the terminal you are already sitting in.
|
|
54
|
+
//
|
|
55
|
+
// Defaults to "worktree" so no installed repo changes behaviour on upgrade.
|
|
56
|
+
// Never inferred from whether `dev`/`docker` are configured: a repo with no
|
|
57
|
+
// dev servers may still want parallel specs, and absence of configuration is
|
|
58
|
+
// not evidence of intent (see .claude/rules/negative-checks.md).
|
|
59
|
+
mode: 'worktree',
|
|
47
60
|
worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
|
|
48
61
|
docker: Object.freeze({
|
|
49
62
|
enabled: true,
|
|
@@ -81,7 +94,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
81
94
|
baseBranch: '',
|
|
82
95
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
83
96
|
// Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
|
|
84
|
-
// `spec-env down` does about the branch `/spec-
|
|
97
|
+
// `spec-env down` does about the branch `/spec-start` pushed: "prompt" (default)
|
|
85
98
|
// plans the delete in its own confirm-first section for the skill to ask about,
|
|
86
99
|
// "never" omits it, "always" folds it into the run-blind command list. Only ever
|
|
87
100
|
// planned for a LANDED branch — see teardown.js.
|
|
@@ -105,6 +118,7 @@ function isObject(value) {
|
|
|
105
118
|
// A fresh, deeply-mutable copy of the defaults to merge onto.
|
|
106
119
|
function defaults() {
|
|
107
120
|
return {
|
|
121
|
+
mode: DEFAULT_CONFIG.mode,
|
|
108
122
|
worktree: { ...DEFAULT_CONFIG.worktree },
|
|
109
123
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
110
124
|
seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
|
|
@@ -257,6 +271,15 @@ function mergeConfig(base, parsed) {
|
|
|
257
271
|
assign(base, parsed, 'registry', 'string')
|
|
258
272
|
assign(base, parsed, 'baseBranch', 'string')
|
|
259
273
|
|
|
274
|
+
// Same treatment as `teardown.deleteRemoteBranch` below, for the same reason:
|
|
275
|
+
// an unrecognised value falls through to the default rather than erroring or
|
|
276
|
+
// being taken literally. The fallback direction matters — "worktree" is the
|
|
277
|
+
// conservative one, so a typo ("Checkout", "in-place") costs an extra terminal
|
|
278
|
+
// session, never a spec's work landing somewhere the author did not choose.
|
|
279
|
+
if (parsed.mode === 'worktree' || parsed.mode === 'checkout') {
|
|
280
|
+
base.mode = parsed.mode
|
|
281
|
+
}
|
|
282
|
+
|
|
260
283
|
if (isObject(parsed.guards)) {
|
|
261
284
|
assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
|
|
262
285
|
assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
|
package/src/env/integrate.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
|
|
18
18
|
* @param {object} config normalised env config (unused today; kept for symmetry).
|
|
19
|
+
* In CHECKOUT mode there is no worktree: the branch is already checked out in the
|
|
20
|
+
* primary checkout, so landing is a rebase in place, a switch to base, and the
|
|
21
|
+
* fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
|
|
22
|
+
* returns is identical so callers need no second code path.
|
|
23
|
+
*
|
|
19
24
|
* @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
|
|
20
25
|
* @returns {object} { blocked, noop, reason, commands, base, branch }
|
|
21
26
|
*/
|
|
@@ -43,4 +48,59 @@ function planIntegrate(spec, config, ctx) {
|
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Pure integrate planner for CHECKOUT mode.
|
|
53
|
+
*
|
|
54
|
+
* The branch lives in the primary checkout, so `git -C <worktree> rebase` — the
|
|
55
|
+
* worktree-mode plan — has nothing to address. Landing is three steps in one
|
|
56
|
+
* repo: rebase onto base, switch to base, fast-forward.
|
|
57
|
+
*
|
|
58
|
+
* The switch is what makes this safe to repeat. Leaving the checkout on the spec
|
|
59
|
+
* branch after landing would mean the next `spec-env up` refuses ("standing on
|
|
60
|
+
* another spec's branch") for a spec that is finished, and the operator would
|
|
61
|
+
* have to know to switch back by hand.
|
|
62
|
+
*
|
|
63
|
+
* ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch }
|
|
64
|
+
*/
|
|
65
|
+
function planIntegrateCheckout(spec, config, ctx) {
|
|
66
|
+
const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
|
|
67
|
+
const branch = spec.branch
|
|
68
|
+
const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
|
|
69
|
+
|
|
70
|
+
// "Already landed" is answered FIRST, before any refusal. A landed spec needs
|
|
71
|
+
// no action wherever the checkout happens to be standing — and after a
|
|
72
|
+
// successful land it is standing on base, so asking "not on the branch" here
|
|
73
|
+
// would refuse the very spec this just finished landing. /spec-complete calls
|
|
74
|
+
// integrate again on exactly that state.
|
|
75
|
+
if (!aheadOfBase) {
|
|
76
|
+
return { ...result, noop: true }
|
|
77
|
+
}
|
|
78
|
+
if (dirty) {
|
|
79
|
+
return {
|
|
80
|
+
...result,
|
|
81
|
+
blocked: true,
|
|
82
|
+
reason: 'the checkout has uncommitted changes — commit the completion first',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// There IS something to land, so where you are standing now matters: the
|
|
86
|
+
// branch is the checkout in this mode, and landing from elsewhere would
|
|
87
|
+
// rebase and fast-forward a branch the operator is not looking at.
|
|
88
|
+
if (onBranch === false) {
|
|
89
|
+
return {
|
|
90
|
+
...result,
|
|
91
|
+
blocked: true,
|
|
92
|
+
reason: `the checkout is not on ${branch} — switch to it before landing`,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
...result,
|
|
98
|
+
commands: [
|
|
99
|
+
`git -C ${checkoutPath} rebase ${base}`,
|
|
100
|
+
`git -C ${checkoutPath} switch ${base}`,
|
|
101
|
+
`git -C ${checkoutPath} merge --ff-only ${branch}`,
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { planIntegrate, planIntegrateCheckout }
|
package/src/env/live.js
CHANGED
|
@@ -134,7 +134,11 @@ function migrationsHit(files, patterns) {
|
|
|
134
134
|
* ctx:
|
|
135
135
|
* primary { onBase, branch, baseBranch } — the guard result for the primary checkout
|
|
136
136
|
* primaryPath absolute path of the primary checkout (the checkout target)
|
|
137
|
+
* inFlight string|null — folder of the spec holding the primary, from
|
|
138
|
+
* its receipt; null when nothing is recorded (a hand-switched
|
|
139
|
+
* branch), which degrades the message to the branch name
|
|
137
140
|
* clean boolean — primary checkout working tree is clean
|
|
141
|
+
* worktreeClean boolean — the SPEC WORKTREE's tree is clean (the rebase runs there)
|
|
138
142
|
* worktreeExists boolean — the spec's worktree is on disk
|
|
139
143
|
* base resolved base branch name (rebase target)
|
|
140
144
|
* baseMainCommit primary HEAD before the switch (receipt / crash recovery)
|
|
@@ -166,9 +170,16 @@ function planTake(spec, config, ctx) {
|
|
|
166
170
|
// (or you) already holds the live instance.
|
|
167
171
|
if (!c.primary || !c.primary.onBase) {
|
|
168
172
|
const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
|
|
173
|
+
// Name the SPEC, not just the branch, and name every way out. `/spec-start`
|
|
174
|
+
// relays this refusal verbatim as its gate, so whatever is missing here is
|
|
175
|
+
// missing from the operator's only explanation of why they are stuck —
|
|
176
|
+
// "release it with /spec-live main" alone reads as the sole option when
|
|
177
|
+
// completing or cancelling the held spec are usually the ones they want.
|
|
178
|
+
const held = c.inFlight ? `${c.inFlight} (branch ${on})` : on
|
|
169
179
|
return block(
|
|
170
|
-
`primary checkout is on ${on}, not ${base} —
|
|
171
|
-
'
|
|
180
|
+
`primary checkout is on ${on}, not ${base} — ${held} already holds it. ` +
|
|
181
|
+
'Free the workbench first: `/spec-complete` if it is finished, ' +
|
|
182
|
+
'`/spec-cancel` if it is not wanted, or `/spec-live main` to park it',
|
|
172
183
|
)
|
|
173
184
|
}
|
|
174
185
|
// 2. Never switch a dirty tree — the checkout is reset back to base on release.
|
|
@@ -177,7 +188,20 @@ function planTake(spec, config, ctx) {
|
|
|
177
188
|
}
|
|
178
189
|
// 3. Need a worktree holding the branch to detach and hand over.
|
|
179
190
|
if (!c.worktreeExists) {
|
|
180
|
-
return block(`${spec.folder} has no worktree — run \`/spec-
|
|
191
|
+
return block(`${spec.folder} has no worktree — run \`/spec-start ${spec.folder}\` first`)
|
|
192
|
+
}
|
|
193
|
+
// 3b. The WORKTREE's own tree, not the primary checkout's. Check 2 above reads
|
|
194
|
+
// the checkout we switch INTO; the rebase runs in the worktree, and git
|
|
195
|
+
// refuses to rebase over uncommitted changes. Checking only the primary
|
|
196
|
+
// let a dirty worktree through to fail at the rebase, where the failure
|
|
197
|
+
// was then reported as a merge conflict — a wrong cause for a real
|
|
198
|
+
// problem, which is worse than no message. Validate the tree the
|
|
199
|
+
// operation actually touches.
|
|
200
|
+
if (c.worktreeClean === false) {
|
|
201
|
+
return block(
|
|
202
|
+
`${spec.folder}'s worktree has uncommitted changes — commit or stash them in ` +
|
|
203
|
+
`${spec.worktreePath} first (the rebase cannot run over them)`,
|
|
204
|
+
)
|
|
181
205
|
}
|
|
182
206
|
// 4. A hotfix is built on an old release tag; checking its branch out under the
|
|
183
207
|
// running dev server risks schema/DB drift breaking the shared instance.
|