@skitterbyte/skitterspec-linear 10.8.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/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.md +3 -3
- package/assets/rules/spec-planning.md +28 -9
- package/assets/skills/spec/SKILL.md +15 -10
- package/assets/skills/spec-bug/SKILL.md +11 -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 +10 -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 +1 -1
- package/assets/skills/spec-to-main/SKILL.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +198 -14
- 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 +2 -2
- package/src/vendor/linear/config.js +1 -1
- package/assets/skills/spec-go/SKILL.md +0 -233
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,6 +1285,17 @@ 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) {
|
|
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
|
+
}
|
|
1154
1299
|
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
1155
1300
|
switch (action) {
|
|
1156
1301
|
case 'status':
|
|
@@ -1210,6 +1355,10 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1210
1355
|
const status = primaryGit(['status', '--porcelain'])
|
|
1211
1356
|
const clean = status !== null && status.length === 0
|
|
1212
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
|
|
1213
1362
|
const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
|
|
1214
1363
|
|
|
1215
1364
|
// Diff base...branch to spot migration / dependency changes (best-effort).
|
|
@@ -1229,7 +1378,9 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1229
1378
|
const plan = planTake(spec, config, {
|
|
1230
1379
|
primary,
|
|
1231
1380
|
primaryPath: dir,
|
|
1381
|
+
inFlight: (readReceipt(dir, config) || {}).spec || null,
|
|
1232
1382
|
clean,
|
|
1383
|
+
worktreeClean,
|
|
1233
1384
|
worktreeExists,
|
|
1234
1385
|
base,
|
|
1235
1386
|
baseMainCommit,
|
|
@@ -1249,10 +1400,19 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1249
1400
|
// Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
|
|
1250
1401
|
const reb = runGit(spec.worktreePath, ['rebase', base])
|
|
1251
1402
|
if (!reb.ok) {
|
|
1252
|
-
|
|
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'])
|
|
1253
1410
|
process.stdout.write(
|
|
1254
|
-
|
|
1255
|
-
`
|
|
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`,
|
|
1256
1416
|
)
|
|
1257
1417
|
return
|
|
1258
1418
|
}
|
|
@@ -1400,7 +1560,7 @@ async function specEnvLiveAbort(dir, config) {
|
|
|
1400
1560
|
function specEnvLiveStatus(dir, config, specArg) {
|
|
1401
1561
|
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
1402
1562
|
|
|
1403
|
-
// 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
|
|
1404
1564
|
// skill branches on to decide whether to skip worktree provisioning and work in
|
|
1405
1565
|
// the primary checkout. The stable `live: yes|no` line is the machine seam.
|
|
1406
1566
|
if (specArg) {
|
|
@@ -1423,9 +1583,23 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
1423
1583
|
const state = onBase
|
|
1424
1584
|
? 'on base — free'
|
|
1425
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
|
+
|
|
1426
1599
|
process.stdout.write(
|
|
1427
1600
|
'spec-env live:\n' +
|
|
1428
1601
|
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
1602
|
+
` in-flight: ${inFlight}\n` +
|
|
1429
1603
|
` receipt: ${summarizeReceipt(receipt)}\n`,
|
|
1430
1604
|
)
|
|
1431
1605
|
}
|
|
@@ -1560,11 +1734,21 @@ async function run(argv) {
|
|
|
1560
1734
|
// Fresh repo (or create-missing): isolation defaults OFF; a flag or an
|
|
1561
1735
|
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1562
1736
|
let isolation = opts.isolation === true
|
|
1737
|
+
let workspaceMode = 'worktree'
|
|
1563
1738
|
if (interactive && !isExistingSetup(dir)) {
|
|
1564
1739
|
const { promptSetup } = require('./prompts.js')
|
|
1565
|
-
|
|
1740
|
+
const answers = await promptSetup({ isolationSeed: isolation })
|
|
1741
|
+
isolation = answers.isolation
|
|
1742
|
+
workspaceMode = answers.mode
|
|
1566
1743
|
}
|
|
1567
|
-
await init({
|
|
1744
|
+
await init({
|
|
1745
|
+
dir,
|
|
1746
|
+
force: opts.force,
|
|
1747
|
+
claudeMd: opts.claudeMd,
|
|
1748
|
+
mode: 'init',
|
|
1749
|
+
isolation,
|
|
1750
|
+
workspaceMode,
|
|
1751
|
+
})
|
|
1568
1752
|
break
|
|
1569
1753
|
}
|
|
1570
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.
|
package/src/env/provision.js
CHANGED
|
@@ -158,4 +158,61 @@ function planUp(spec, alloc, config) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
|
|
163
|
+
* checkout and there is no worktree at all.
|
|
164
|
+
*
|
|
165
|
+
* Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
|
|
166
|
+
*
|
|
167
|
+
* ctx: { current, base, onBase, clean, branchExists }
|
|
168
|
+
*
|
|
169
|
+
* What it refuses, and why each is a refusal rather than a warning:
|
|
170
|
+
* - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
|
|
171
|
+
* new branch. That is silent and it is the operator's work, so it is theirs
|
|
172
|
+
* to place, not ours.
|
|
173
|
+
* - standing on another branch, because switching away from it is a decision
|
|
174
|
+
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
175
|
+
* refusal — it is the re-run, and the answer is "already attached".
|
|
176
|
+
*
|
|
177
|
+
* There is no bootstrap and no opener: the primary checkout already has its
|
|
178
|
+
* dependencies, and no new session is being opened.
|
|
179
|
+
*/
|
|
180
|
+
function planCheckoutUp(spec, ctx, config) {
|
|
181
|
+
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
182
|
+
const result = {
|
|
183
|
+
mode: 'checkout',
|
|
184
|
+
blocked: false,
|
|
185
|
+
reason: null,
|
|
186
|
+
attached: false,
|
|
187
|
+
branch: spec.branch,
|
|
188
|
+
checkoutPath: ctx.checkoutPath || null,
|
|
189
|
+
commands: [],
|
|
190
|
+
}
|
|
191
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
192
|
+
|
|
193
|
+
// Order matters: report "already attached" before anything else, so a re-run
|
|
194
|
+
// on the spec's own branch is never refused for a dirty tree it legitimately
|
|
195
|
+
// has — you are mid-phase, with the phase's own edits in progress.
|
|
196
|
+
if (ctx.current && ctx.current === spec.branch) {
|
|
197
|
+
return { ...result, attached: true }
|
|
198
|
+
}
|
|
199
|
+
if (!ctx.clean) {
|
|
200
|
+
return block(
|
|
201
|
+
'the primary checkout has uncommitted changes — commit or stash them first ' +
|
|
202
|
+
'(switching would carry them onto the new branch)',
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (!ctx.onBase) {
|
|
206
|
+
return block(
|
|
207
|
+
`the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
|
|
208
|
+
'finish or park that branch first; checkout mode holds one spec at a time',
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
result.commands.push(
|
|
213
|
+
ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
|
|
214
|
+
)
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
|
package/src/env/teardown.js
CHANGED
|
@@ -105,7 +105,7 @@ function planDown(spec, config, flags, ctx) {
|
|
|
105
105
|
// different question from ours: it also declines a branch that is ahead of its
|
|
106
106
|
// upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
|
|
107
107
|
// even though it is merged to HEAD`. That fires on the ordinary spec flow —
|
|
108
|
-
// `/spec-
|
|
108
|
+
// `/spec-start` pushes the branch when it provisions, and the phase commits after
|
|
109
109
|
// it are landed locally rather than pushed — so teardown meets a branch whose
|
|
110
110
|
// every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
|
|
111
111
|
// of base) already establishes what we actually care about, and establishes it
|
|
@@ -122,7 +122,7 @@ function planDown(spec, config, flags, ctx) {
|
|
|
122
122
|
|
|
123
123
|
// --- delete the branch on the remote (planned, never run here) ---
|
|
124
124
|
//
|
|
125
|
-
// `/spec-
|
|
125
|
+
// `/spec-start` pushes the branch at provision time, so a completed spec otherwise
|
|
126
126
|
// leaves a merged branch on the remote forever. Cleaning that up is the goal;
|
|
127
127
|
// doing it safely is the constraint.
|
|
128
128
|
//
|
|
@@ -185,4 +185,42 @@ function blocked(reason) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Pure teardown planner for CHECKOUT mode.
|
|
190
|
+
*
|
|
191
|
+
* There is no worktree to remove, no slot and no volumes — the only thing a
|
|
192
|
+
* finished spec leaves behind is its branch, and the checkout standing on it.
|
|
193
|
+
*
|
|
194
|
+
* Order is load-bearing: git refuses to delete the branch you are on, so the
|
|
195
|
+
* switch to base must come first. It is also what returns the checkout to a
|
|
196
|
+
* state the next `spec-env up` will accept.
|
|
197
|
+
*
|
|
198
|
+
* The same "are these commits recoverable?" question decides `-d` vs `-D`, and
|
|
199
|
+
* it is asked exactly as worktree-mode teardown asks it — a landed branch is
|
|
200
|
+
* safe to force-delete because its commits are on base (or under a tag); an
|
|
201
|
+
* unlanded one is not, and is refused rather than quietly dropped.
|
|
202
|
+
*
|
|
203
|
+
* ctx: { dirty, landed, onBranch, base, checkoutPath }
|
|
204
|
+
*/
|
|
205
|
+
function planDownCheckout(spec, config, flags, ctx) {
|
|
206
|
+
const { dirty, landed, onBranch, base, checkoutPath } = ctx || {}
|
|
207
|
+
const force = Boolean(flags && flags.force)
|
|
208
|
+
const result = { mode: 'checkout', blocked: false, reason: null, commands: [], branch: spec.branch }
|
|
209
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
210
|
+
|
|
211
|
+
if (!force) {
|
|
212
|
+
if (config.guards.refuseTeardownIfDirty && dirty) {
|
|
213
|
+
return block('the checkout has uncommitted changes')
|
|
214
|
+
}
|
|
215
|
+
if (!landed) {
|
|
216
|
+
return block(`${spec.branch} is not merged into ${base} — landing it first is what makes the delete safe`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const commands = []
|
|
221
|
+
if (onBranch !== false) commands.push(`git -C ${checkoutPath} switch ${base}`)
|
|
222
|
+
commands.push(`git -C ${checkoutPath} branch ${landed ? '-D' : '-d'} ${spec.branch}`)
|
|
223
|
+
return { ...result, commands }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { planDown, planDownCheckout }
|