@skitterbyte/skitterspec-linear 10.8.0 → 12.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 +88 -0
- package/README.md +3 -3
- package/assets/claude-md-section.md +20 -48
- package/assets/core/SETUP.md +1 -1
- package/assets/core/env.config.json.example +7 -1
- package/assets/core/env.config.md +56 -11
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +12 -11
- package/assets/rules/spec-planning.md +53 -13
- package/assets/skills/spec/SKILL.md +46 -18
- package/assets/skills/spec-bug/SKILL.md +32 -27
- package/assets/skills/spec-cancel/SKILL.md +26 -0
- package/assets/skills/spec-complete/SKILL.md +70 -17
- package/assets/skills/spec-hotfix/SKILL.md +40 -19
- package/assets/skills/spec-init/SKILL.md +31 -8
- package/assets/skills/spec-linear-setup/SKILL.md +32 -7
- package/assets/skills/spec-next/SKILL.md +141 -0
- package/assets/skills/spec-push/SKILL.md +15 -16
- package/assets/skills/spec-review/SKILL.md +24 -11
- package/assets/skills/spec-start/SKILL.md +226 -0
- package/assets/skills/spec-status/SKILL.md +2 -2
- package/assets/skills/spec-sync/SKILL.md +8 -8
- package/assets/skills/spec-to-main/SKILL.md +21 -19
- package/package.json +1 -1
- package/src/cli.js +405 -15
- package/src/env/classify.js +91 -0
- package/src/env/config.js +39 -1
- package/src/env/integrate.js +61 -1
- package/src/env/live.js +27 -3
- package/src/env/provision.js +196 -5
- package/src/env/resolve.js +1 -0
- package/src/env/teardown.js +41 -3
- package/src/gating.js +155 -0
- package/src/init.js +45 -9
- package/src/prompts.js +41 -4
- package/src/vendor/linear/cli-sync.js +22 -2
- package/src/vendor/linear/config.js +1 -1
- package/src/vendor/sync-core/src/compare.js +25 -2
- package/assets/skills/spec-go/SKILL.md +0 -233
package/src/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
resolveBaseBranch,
|
|
23
23
|
resolvePrimaryCheckout,
|
|
24
24
|
assertPrimaryOnMain,
|
|
25
|
+
currentBranch,
|
|
25
26
|
repoInfo,
|
|
26
27
|
expandTokens,
|
|
27
28
|
splitPrefix,
|
|
@@ -37,10 +38,10 @@ const {
|
|
|
37
38
|
planAbort,
|
|
38
39
|
} = require('./env/live.js')
|
|
39
40
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
40
|
-
const { planUp } = require('./env/provision.js')
|
|
41
|
-
const { planDown } = require('./env/teardown.js')
|
|
41
|
+
const { planUp, planCheckoutUp } = require('./env/provision.js')
|
|
42
|
+
const { planDown, planDownCheckout } = require('./env/teardown.js')
|
|
42
43
|
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
43
|
-
const { planIntegrate } = require('./env/integrate.js')
|
|
44
|
+
const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
|
|
44
45
|
const { planHotfixLand } = require('./env/hotfix.js')
|
|
45
46
|
const { planDev } = require('./env/dev.js')
|
|
46
47
|
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
@@ -94,6 +95,11 @@ Usage:
|
|
|
94
95
|
hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
|
|
95
96
|
status list provisioned specs + port blocks
|
|
96
97
|
resolve <spec> print resolved slug/type/branch/paths
|
|
98
|
+
skitterspec gating <cmd> Release-gating check (opt-in; needs
|
|
99
|
+
specs/.core/gating.config.json). Subcommands:
|
|
100
|
+
check [spec] report specs with no recorded gating
|
|
101
|
+
decision (--all, --json). Advisory:
|
|
102
|
+
always exits 0, never blocks.
|
|
97
103
|
skitterspec --help Show this help
|
|
98
104
|
skitterspec --version Print version
|
|
99
105
|
|
|
@@ -105,6 +111,8 @@ Options (init / update):
|
|
|
105
111
|
--diff (update) Show the upstream changes each customized
|
|
106
112
|
file declined, as a unified diff
|
|
107
113
|
--dir <path> Target project dir (default: positional arg or cwd)
|
|
114
|
+
--gating (init) Adopt release gating: each spec records whether
|
|
115
|
+
it ships behind a feature flag
|
|
108
116
|
--no-claude-md Skip creating/patching CLAUDE.md
|
|
109
117
|
--yes, -y Accept defaults; skip the interactive setup prompts
|
|
110
118
|
--isolation / --no-isolation Enable/skip per-spec isolation (a git
|
|
@@ -139,6 +147,10 @@ function parse(argv) {
|
|
|
139
147
|
else if (a === '--yes' || a === '-y') opts.yes = true
|
|
140
148
|
else if (a === '--isolation') opts.isolation = true
|
|
141
149
|
else if (a === '--no-isolation') opts.isolation = false
|
|
150
|
+
else if (a === '--gating') opts.gating = true
|
|
151
|
+
else if (a === '--no-gating') opts.gating = false
|
|
152
|
+
else if (a === '--all') opts.all = true
|
|
153
|
+
else if (a === '--json') opts.json = true
|
|
142
154
|
else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
|
|
143
155
|
else if (a === '--resync') opts.resync = true
|
|
144
156
|
else if (a === '--reset') opts.reset = true
|
|
@@ -233,9 +245,228 @@ function specEnvStatus(dir, config) {
|
|
|
233
245
|
// the /spec-env skill executes (git worktree add, docker compose up, .env,
|
|
234
246
|
// opener). This creates no worktree and starts no stack — the caller runs the
|
|
235
247
|
// printed commands. Keep the output's verb honest about that.
|
|
248
|
+
|
|
249
|
+
// git quotes a path containing unusual bytes and C-escapes it. Unquote what we
|
|
250
|
+
// can; anything we cannot parse confidently is returned as-is, which makes it
|
|
251
|
+
// fail the spec-folder comparison and land in `foreign` — a refusal, which is the
|
|
252
|
+
// safe direction to be wrong in.
|
|
253
|
+
function unquotePath(p) {
|
|
254
|
+
if (!p.startsWith('"') || !p.endsWith('"')) return p
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(p)
|
|
257
|
+
} catch {
|
|
258
|
+
return p
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Repo-relative paths of everything uncommitted. Returns null when git could not
|
|
264
|
+
* be read at all — the caller must treat that as "nobody looked", never "clean".
|
|
265
|
+
*
|
|
266
|
+
* Two prefix-free listings rather than `git status --porcelain`, deliberately.
|
|
267
|
+
* Porcelain prefixes every path with a two-character status field, and the shared
|
|
268
|
+
* git reader TRIMS its output — which eats the leading space of the first line
|
|
269
|
+
* only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
|
|
270
|
+
* These emit bare paths, so there is no offset to get wrong. `--others` also
|
|
271
|
+
* lists untracked files INDIVIDUALLY, where porcelain collapses them into their
|
|
272
|
+
* topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
|
|
273
|
+
* an ancestor attributable to no single spec, and so refusing the very tree this
|
|
274
|
+
* gate exists to accept. Both were found by running it, not by reading it.
|
|
275
|
+
*/
|
|
276
|
+
function dirtyPaths(git) {
|
|
277
|
+
const lists = [
|
|
278
|
+
git(['diff', '--name-only', 'HEAD']),
|
|
279
|
+
git(['ls-files', '--others', '--exclude-standard']),
|
|
280
|
+
]
|
|
281
|
+
if (lists.every((l) => l === null)) return null
|
|
282
|
+
const out = []
|
|
283
|
+
for (const list of lists) {
|
|
284
|
+
if (!list) continue
|
|
285
|
+
for (const line of list.split('\n')) {
|
|
286
|
+
const q = line.trim()
|
|
287
|
+
if (q) out.push(unquotePath(q))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Is this spec new to git? Asked directly, so the commit subject does not depend
|
|
294
|
+
// on the shape of `git status` output.
|
|
295
|
+
function specIsUntracked(dir, git, spec) {
|
|
296
|
+
const rel = path.relative(dir, spec.path).split(path.sep).join('/')
|
|
297
|
+
const tracked = git(['ls-files', '--', rel])
|
|
298
|
+
return tracked === null ? null : tracked.length === 0
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Is this spec present in the commit the worktree will fork from?
|
|
303
|
+
*
|
|
304
|
+
* A worktree forks from HEAD (`git worktree add -b`), so a spec absent there
|
|
305
|
+
* yields a branch missing the very spec it is for — and a clean working tree
|
|
306
|
+
* cannot detect that, because the spec may be perfectly well committed elsewhere.
|
|
307
|
+
* This asks the positive question rather than inferring it from cleanliness.
|
|
308
|
+
*
|
|
309
|
+
* A HOTFIX IS EXEMPT, and not as an edge case: it forks from a released tag
|
|
310
|
+
* (`spec.baseRef`) that by definition predates the spec describing the fix, so
|
|
311
|
+
* the spec is *supposed* to be absent there. Checking it would refuse every
|
|
312
|
+
* hotfix — an accusation aimed squarely at correct behaviour.
|
|
313
|
+
*
|
|
314
|
+
* Returns `{ onFork, foundOn }`; `onFork: null` means "cannot tell" (a hotfix, or
|
|
315
|
+
* a git that would not answer), which the planner routes to carrying on.
|
|
316
|
+
*/
|
|
317
|
+
function specOnForkPoint(dir, git, spec) {
|
|
318
|
+
if (spec.baseRef) return { onFork: null, foundOn: null }
|
|
319
|
+
const rel = path.relative(dir, spec.path).split(path.sep).join('/')
|
|
320
|
+
if (git(['cat-file', '-e', `HEAD:${rel}/00-overview.md`]) !== null) {
|
|
321
|
+
return { onFork: true, foundOn: null }
|
|
322
|
+
}
|
|
323
|
+
// Best-effort: name the branch that does have it, so the refusal is actionable.
|
|
324
|
+
let foundOn = null
|
|
325
|
+
const sha = git(['log', '--all', '--format=%H', '-1', '--', rel])
|
|
326
|
+
if (sha) {
|
|
327
|
+
const branches = git(['branch', '--contains', sha, '--format=%(refname:short)'])
|
|
328
|
+
if (branches) foundOn = branches.split('\n').map((b) => b.trim()).filter(Boolean)[0] || null
|
|
329
|
+
}
|
|
330
|
+
return { onFork: false, foundOn }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
// Say what is about to be committed, and why it qualified. The commit is planned
|
|
335
|
+
// on the operator's behalf, so it is never allowed to be a surprise: the paths are
|
|
336
|
+
// listed before the commands that stage them.
|
|
337
|
+
function specCommitLines(plan, folder) {
|
|
338
|
+
if (!plan.specCommit) return []
|
|
339
|
+
const out = ['', ` uncommitted, and all of it is ${folder}'s — it will be committed first:`]
|
|
340
|
+
for (const p of plan.specCommit.paths) out.push(` ${p}`)
|
|
341
|
+
return out
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
|
|
345
|
+
// planner, and prints the plan or the refusal.
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `skitterspec gating check [spec] [--all] [--json]`
|
|
349
|
+
*
|
|
350
|
+
* ADVISORY BY CONSTRUCTION. It reports and exits 0 — always, including when it
|
|
351
|
+
* finds something. The point of release gating is that the decision is recorded
|
|
352
|
+
* and reviewable, not that a machine enforces it: a project that has not decided
|
|
353
|
+
* yet is not broken, and a check that stopped someone's work over a missing
|
|
354
|
+
* header would be a worse failure than the omission it names.
|
|
355
|
+
*/
|
|
356
|
+
function gatingCheck(dir, argv) {
|
|
357
|
+
const { opts, positional } = parse(argv)
|
|
358
|
+
const { checkGating, activeSpecs } = require('./gating.js')
|
|
359
|
+
|
|
360
|
+
let specs = null
|
|
361
|
+
if (positional.length && !opts.all) {
|
|
362
|
+
const name = path.basename(positional[0])
|
|
363
|
+
const found = activeSpecs(dir).filter((s) => s.folder === name)
|
|
364
|
+
if (!found.length) {
|
|
365
|
+
// Not an accusation: a name that matches no ACTIVE spec is usually a
|
|
366
|
+
// finished one, which gating never covers anyway.
|
|
367
|
+
process.stdout.write(`gating: no active spec named ${name} — nothing to check.\n`)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
specs = found
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = checkGating(dir, specs)
|
|
374
|
+
|
|
375
|
+
if (opts.json) {
|
|
376
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n')
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (!result.configured) {
|
|
380
|
+
process.stdout.write('gating: not configured — nothing to check.\n')
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
if (!result.findings.length) {
|
|
384
|
+
process.stdout.write(
|
|
385
|
+
`gating: ${result.checked} spec(s) checked — every one records a decision.\n`,
|
|
386
|
+
)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
const lines = []
|
|
390
|
+
for (const f of result.findings) {
|
|
391
|
+
lines.push(
|
|
392
|
+
f.kind === 'missing'
|
|
393
|
+
? ` ${f.folder} (${f.bucket}): no Gating: header`
|
|
394
|
+
: ` ${f.folder} (${f.bucket}): Gating: "${f.raw}" says nothing — a bare "none" is not a reason`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
lines.push('')
|
|
398
|
+
lines.push(' decide, then record it on 00-overview.md beside Stack:')
|
|
399
|
+
lines.push(' > **Gating:** <flag name — or "none: <one-line reason>">')
|
|
400
|
+
if (result.guidance) lines.push(` see ${result.guidance}`)
|
|
401
|
+
process.stdout.write(
|
|
402
|
+
`gating: ${result.findings.length} of ${result.checked} spec(s) record no decision\n` +
|
|
403
|
+
lines.join('\n') +
|
|
404
|
+
'\n',
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function specEnvUpCheckout(dir, config, spec) {
|
|
409
|
+
const git = gitReader(dir)
|
|
410
|
+
const primary = assertPrimaryOnMain(config, git)
|
|
411
|
+
const base = resolveBaseBranch(config, git)
|
|
412
|
+
const status = git(['status', '--porcelain', '-uall'])
|
|
413
|
+
const onFork = specOnForkPoint(dir, git, spec)
|
|
414
|
+
|
|
415
|
+
const plan = planCheckoutUp(
|
|
416
|
+
spec,
|
|
417
|
+
{
|
|
418
|
+
current: primary.branch,
|
|
419
|
+
base,
|
|
420
|
+
onBase: primary.onBase,
|
|
421
|
+
// A null status means git could not be read at all. Treated as NOT clean:
|
|
422
|
+
// the harmless outcome of being wrong is a refusal the operator can act
|
|
423
|
+
// on, and the harmful one is carrying their work onto a new branch.
|
|
424
|
+
clean: status !== null && status.length === 0,
|
|
425
|
+
dirtyPaths: dirtyPaths(git),
|
|
426
|
+
specOnFork: onFork.onFork,
|
|
427
|
+
specFoundOn: onFork.foundOn,
|
|
428
|
+
forkRef: primary.branch || 'HEAD',
|
|
429
|
+
specUntracked: specIsUntracked(dir, git, spec),
|
|
430
|
+
branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
|
|
431
|
+
checkoutPath: dir,
|
|
432
|
+
},
|
|
433
|
+
config,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if (plan.blocked) {
|
|
437
|
+
process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const out = [
|
|
442
|
+
`spec-env up: ${spec.folder} ` +
|
|
443
|
+
(plan.attached ? '(already on this branch — nothing to do)' : '(plan — nothing created yet)'),
|
|
444
|
+
'',
|
|
445
|
+
` mode: checkout (branch built in the primary checkout)`,
|
|
446
|
+
` checkout: ${plan.checkoutPath}`,
|
|
447
|
+
` branch: ${plan.branch}`,
|
|
448
|
+
' stack: checkout-only (no worktree, no docker, no port block)',
|
|
449
|
+
]
|
|
450
|
+
out.push(...specCommitLines(plan, spec.folder))
|
|
451
|
+
if (plan.commands.length) {
|
|
452
|
+
out.push('')
|
|
453
|
+
out.push(' to provision, run:')
|
|
454
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
455
|
+
}
|
|
456
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
457
|
+
}
|
|
458
|
+
|
|
236
459
|
function specEnvUp(dir, config, specArg) {
|
|
237
460
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
238
461
|
|
|
462
|
+
// Checkout mode: the branch is built in the primary checkout, so none of the
|
|
463
|
+
// worktree machinery below applies — no slot, no trust entry, no bootstrap and
|
|
464
|
+
// no opener. Handled first precisely so none of that runs by accident.
|
|
465
|
+
if (config.mode === 'checkout') {
|
|
466
|
+
specEnvUpCheckout(dir, config, spec)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
|
|
239
470
|
// Live-safe: if this spec is already live on the primary checkout (its branch was
|
|
240
471
|
// branch-switched in by `live take`), a `git worktree add` would fail — the branch
|
|
241
472
|
// is checked out there. Point the operator at the primary checkout rather than
|
|
@@ -251,7 +482,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
251
482
|
|
|
252
483
|
// Trust the shared worktree root so edits into the freshly-provisioned worktree
|
|
253
484
|
// 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-
|
|
485
|
+
// every provision for teammates who only cloned and ran /spec-start.
|
|
255
486
|
const worktreeRootAbs = path.dirname(spec.worktreePath)
|
|
256
487
|
const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
|
|
257
488
|
|
|
@@ -272,7 +503,24 @@ function specEnvUp(dir, config, specArg) {
|
|
|
272
503
|
attached = fs.existsSync(spec.worktreePath)
|
|
273
504
|
}
|
|
274
505
|
|
|
275
|
-
|
|
506
|
+
// The tree gate: the same facts the checkout planner gets. A worktree forks
|
|
507
|
+
// from base, so an uncommitted spec would produce a branch without it.
|
|
508
|
+
const upGit = gitReader(dir)
|
|
509
|
+
const upStatus = upGit(['status', '--porcelain'])
|
|
510
|
+
const upOnFork = specOnForkPoint(dir, upGit, spec)
|
|
511
|
+
const plan = planUp(spec, { slot, attached }, config, {
|
|
512
|
+
clean: upStatus !== null && upStatus.length === 0,
|
|
513
|
+
dirtyPaths: dirtyPaths(upGit),
|
|
514
|
+
specOnFork: upOnFork.onFork,
|
|
515
|
+
specFoundOn: upOnFork.foundOn,
|
|
516
|
+
forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
|
|
517
|
+
specUntracked: specIsUntracked(dir, upGit, spec),
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
if (plan.blocked) {
|
|
521
|
+
process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
276
524
|
|
|
277
525
|
const out = []
|
|
278
526
|
// `up` is a planner: it prints commands for the caller to run and creates no
|
|
@@ -295,6 +543,10 @@ function specEnvUp(dir, config, specArg) {
|
|
|
295
543
|
} else {
|
|
296
544
|
out.push(' stack: worktree-only (no docker, no port block)')
|
|
297
545
|
}
|
|
546
|
+
// The loader falls back silently on an unrecognised `mode`, so this line is
|
|
547
|
+
// the operator's only evidence of which mode actually resolved — print it
|
|
548
|
+
// whenever it was set explicitly, right or wrong.
|
|
549
|
+
out.push(' mode: worktree (each spec gets its own checkout)')
|
|
298
550
|
if (trust.reason === 'malformed') {
|
|
299
551
|
out.push(
|
|
300
552
|
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
@@ -306,6 +558,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
306
558
|
`(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
|
|
307
559
|
)
|
|
308
560
|
}
|
|
561
|
+
out.push(...specCommitLines(plan, spec.folder))
|
|
309
562
|
out.push('')
|
|
310
563
|
out.push(' to provision, run:')
|
|
311
564
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
@@ -429,12 +682,45 @@ function compactTimestamp() {
|
|
|
429
682
|
// when the spec was never provisioned / already torn down. Deliberately does NOT
|
|
430
683
|
// touch the trusted worktree root in .claude/settings.local.json — that entry is
|
|
431
684
|
// the shared parent of every spec's worktree and harmless when empty; removing it
|
|
432
|
-
// would just re-prompt on the next /spec-
|
|
685
|
+
// would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
|
|
433
686
|
function specEnvDown(dir, config, specArg, flags) {
|
|
434
687
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
435
688
|
|
|
436
689
|
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
437
690
|
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
691
|
+
// Checkout mode first: there is no worktree and no registry slot by design, so
|
|
692
|
+
// the "not provisioned" guard below would report a live spec as absent.
|
|
693
|
+
if (config.mode === 'checkout') {
|
|
694
|
+
const dgit = gitReader(dir)
|
|
695
|
+
const dbase = resolveBaseBranch(config, dgit)
|
|
696
|
+
if (dgit(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) === null) {
|
|
697
|
+
process.stdout.write(`spec-env down: ${spec.folder} has no branch — nothing to do.\n`)
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
const dst = dgit(['status', '--porcelain'])
|
|
701
|
+
const contains = dgit(['branch', '--contains', spec.branch, '--list', dbase])
|
|
702
|
+
const dplan = planDownCheckout(spec, config, flags, {
|
|
703
|
+
dirty: dst === null || dst.length > 0,
|
|
704
|
+
landed: Boolean(contains && contains.trim()),
|
|
705
|
+
onBranch: dgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
706
|
+
base: dbase,
|
|
707
|
+
checkoutPath: dir,
|
|
708
|
+
})
|
|
709
|
+
if (dplan.blocked) {
|
|
710
|
+
process.stdout.write(
|
|
711
|
+
`spec-env down: blocked — ${dplan.reason}.\n` +
|
|
712
|
+
'Re-run with --force to tear down anyway (deletes the branch).\n',
|
|
713
|
+
)
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
const dout = [`spec-env down: ${spec.folder}`, '',
|
|
717
|
+
' mode: checkout (no worktree, no slot, no volumes)',
|
|
718
|
+
` branch: ${dplan.branch}`, '', ' run these:']
|
|
719
|
+
for (const cmd of dplan.commands) dout.push(` ${cmd}`)
|
|
720
|
+
process.stdout.write(dout.join('\n') + '\n')
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
|
|
438
724
|
const registry = readRegistry(dir, config)
|
|
439
725
|
const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
|
|
440
726
|
if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
|
|
@@ -566,7 +852,7 @@ function liveWorktreePaths(dir) {
|
|
|
566
852
|
* bucket disappears the moment it empties.
|
|
567
853
|
*
|
|
568
854
|
* Three outcomes, never two: resolved → that spec; several candidates and no cwd
|
|
569
|
-
* hint → throw, listing them; none → throw, pointing at /spec-
|
|
855
|
+
* hint → throw, listing them; none → throw, pointing at /spec-start. *Cannot tell*
|
|
570
856
|
* never becomes a guess.
|
|
571
857
|
*
|
|
572
858
|
* BLIND SPOT: a spec taken live with `/spec-live` has had its branch moved into
|
|
@@ -603,7 +889,7 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
|
|
|
603
889
|
if (provisioned.length === 0) {
|
|
604
890
|
throw new Error(
|
|
605
891
|
'no spec given, and no spec has a worktree — name one explicitly, or run ' +
|
|
606
|
-
'/spec-
|
|
892
|
+
'/spec-start to provision it.',
|
|
607
893
|
)
|
|
608
894
|
}
|
|
609
895
|
throw new Error(
|
|
@@ -764,6 +1050,39 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
764
1050
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
765
1051
|
const base = resolveBaseBranch(config, gitReader(dir))
|
|
766
1052
|
|
|
1053
|
+
// Checkout mode short-circuits everything below. The live handling in
|
|
1054
|
+
// particular reads "primary is on the spec's branch" as a live session — true
|
|
1055
|
+
// in worktree mode, and simply where the branch LIVES in checkout mode, so it
|
|
1056
|
+
// would refuse to land a spec sitting exactly where it belongs.
|
|
1057
|
+
if (config.mode === 'checkout') {
|
|
1058
|
+
const cgit = gitReader(dir)
|
|
1059
|
+
const cst = cgit(['status', '--porcelain'])
|
|
1060
|
+
const cahead = cgit(['rev-list', '--count', `${base}..${spec.branch}`])
|
|
1061
|
+
const cplan = planIntegrateCheckout(spec, config, {
|
|
1062
|
+
dirty: cst === null || cst.length > 0,
|
|
1063
|
+
base,
|
|
1064
|
+
aheadOfBase: cahead !== null && Number(cahead) > 0,
|
|
1065
|
+
checkoutPath: dir,
|
|
1066
|
+
onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
1067
|
+
})
|
|
1068
|
+
if (cplan.blocked) {
|
|
1069
|
+
process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
if (cplan.noop) {
|
|
1073
|
+
process.stdout.write(
|
|
1074
|
+
`spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
|
|
1075
|
+
)
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
const cout = [`spec-env integrate: ${spec.folder}`, '', ' mode: checkout',
|
|
1079
|
+
` base: ${base}`, ` branch: ${cplan.branch}`, '',
|
|
1080
|
+
' run these (abort the rebase on conflict):']
|
|
1081
|
+
for (const cmd of cplan.commands) cout.push(` ${cmd}`)
|
|
1082
|
+
process.stdout.write(cout.join('\n') + '\n')
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
|
|
767
1086
|
// Live-aware: if this spec is live on the primary checkout (branch-switched by
|
|
768
1087
|
// `live take`), end the live session first — release back to base, re-isolate the
|
|
769
1088
|
// branch, clear the receipt — so the normal rebase→ff plan below applies
|
|
@@ -798,7 +1117,7 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
798
1117
|
const liveWtGit = gitReader(spec.worktreePath)
|
|
799
1118
|
if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
|
|
800
1119
|
// Detached worktree HEAD: any commits ahead of the branch ref (e.g. made by a
|
|
801
|
-
//
|
|
1120
|
+
// a build that committed in the worktree) would be abandoned by the re-isolate `switch` below.
|
|
802
1121
|
const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
|
|
803
1122
|
const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
|
|
804
1123
|
if (stranded !== null && Number(stranded) > 0) {
|
|
@@ -1050,6 +1369,16 @@ function proxyProcFor(config, routesFileAbs) {
|
|
|
1050
1369
|
// bundled proxy pointing at that spec's warm dev servers. `connect main` stops
|
|
1051
1370
|
// the proxy so the primary checkout owns the canonical ports again.
|
|
1052
1371
|
async function specEnvConnect(dir, config, specArg) {
|
|
1372
|
+
// Both verbs exist to route around the work living somewhere other than the
|
|
1373
|
+
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
1374
|
+
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
1375
|
+
if (config.mode === 'checkout') {
|
|
1376
|
+
process.stdout.write(
|
|
1377
|
+
'spec-env connect: not applicable in checkout mode — the spec is built in the ' +
|
|
1378
|
+
'primary checkout, so your dev server already serves it on the canonical ports.\\n',
|
|
1379
|
+
)
|
|
1380
|
+
return
|
|
1381
|
+
}
|
|
1053
1382
|
const sdir = stateDirLabel(config)
|
|
1054
1383
|
const abs = (rel) => path.resolve(dir, rel)
|
|
1055
1384
|
const routesFile = `${sdir}/proxy.json`
|
|
@@ -1151,6 +1480,17 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
|
|
|
1151
1480
|
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
1152
1481
|
// (release/abort land in a later phase).
|
|
1153
1482
|
async function specEnvLive(dir, config, positional) {
|
|
1483
|
+
// Both verbs exist to route around the work living somewhere other than the
|
|
1484
|
+
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
1485
|
+
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
1486
|
+
if (config.mode === 'checkout') {
|
|
1487
|
+
process.stdout.write(
|
|
1488
|
+
'spec-env live: not applicable in checkout mode — the spec branch is already ' +
|
|
1489
|
+
'checked out here. `mode: checkout` is the permanent form of what live overlay ' +
|
|
1490
|
+
'does temporarily.\\n',
|
|
1491
|
+
)
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1154
1494
|
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
1155
1495
|
switch (action) {
|
|
1156
1496
|
case 'status':
|
|
@@ -1210,6 +1550,10 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1210
1550
|
const status = primaryGit(['status', '--porcelain'])
|
|
1211
1551
|
const clean = status !== null && status.length === 0
|
|
1212
1552
|
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
1553
|
+
// The tree the rebase actually runs in. Unreadable → treated as dirty: being
|
|
1554
|
+
// wrong that way costs a message, the other way moves work we could not see.
|
|
1555
|
+
const wtStatus = worktreeExists ? gitReader(spec.worktreePath)(['status', '--porcelain']) : ''
|
|
1556
|
+
const worktreeClean = wtStatus !== null && wtStatus.length === 0
|
|
1213
1557
|
const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
|
|
1214
1558
|
|
|
1215
1559
|
// Diff base...branch to spot migration / dependency changes (best-effort).
|
|
@@ -1229,7 +1573,9 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1229
1573
|
const plan = planTake(spec, config, {
|
|
1230
1574
|
primary,
|
|
1231
1575
|
primaryPath: dir,
|
|
1576
|
+
inFlight: (readReceipt(dir, config) || {}).spec || null,
|
|
1232
1577
|
clean,
|
|
1578
|
+
worktreeClean,
|
|
1233
1579
|
worktreeExists,
|
|
1234
1580
|
base,
|
|
1235
1581
|
baseMainCommit,
|
|
@@ -1249,10 +1595,19 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1249
1595
|
// Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
|
|
1250
1596
|
const reb = runGit(spec.worktreePath, ['rebase', base])
|
|
1251
1597
|
if (!reb.ok) {
|
|
1252
|
-
|
|
1598
|
+
// A rebase fails two ways and they need different answers. It can REFUSE TO
|
|
1599
|
+
// START (unstaged changes, a missing base) — nothing to abort, nothing to
|
|
1600
|
+
// resolve — or start and CONFLICT. Calling both "hit conflicts" sent people
|
|
1601
|
+
// hunting a conflict that did not exist, and `--abort` on a rebase that
|
|
1602
|
+
// never began discarded git's own explanation of what was actually wrong.
|
|
1603
|
+
const started = runGit(spec.worktreePath, ['rebase', '--show-current-patch']).ok
|
|
1604
|
+
if (started) runGit(spec.worktreePath, ['rebase', '--abort'])
|
|
1253
1605
|
process.stdout.write(
|
|
1254
|
-
|
|
1255
|
-
`
|
|
1606
|
+
started
|
|
1607
|
+
? `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
|
|
1608
|
+
`resolve them in ${spec.worktreePath}, then retry.\n`
|
|
1609
|
+
: `spec-env live take: rebase of ${spec.branch} onto ${base} could not start — ` +
|
|
1610
|
+
`git said:\n ${reb.err.split('\n').join('\n ')}\n`,
|
|
1256
1611
|
)
|
|
1257
1612
|
return
|
|
1258
1613
|
}
|
|
@@ -1400,7 +1755,7 @@ async function specEnvLiveAbort(dir, config) {
|
|
|
1400
1755
|
function specEnvLiveStatus(dir, config, specArg) {
|
|
1401
1756
|
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
1402
1757
|
|
|
1403
|
-
// Per-spec query (`live status <spec>`): a clear yes/no verdict
|
|
1758
|
+
// Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
|
|
1404
1759
|
// skill branches on to decide whether to skip worktree provisioning and work in
|
|
1405
1760
|
// the primary checkout. The stable `live: yes|no` line is the machine seam.
|
|
1406
1761
|
if (specArg) {
|
|
@@ -1423,9 +1778,23 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
1423
1778
|
const state = onBase
|
|
1424
1779
|
? 'on base — free'
|
|
1425
1780
|
: `feature in control — not on ${baseBranch}`
|
|
1781
|
+
|
|
1782
|
+
// `in-flight:` is a MACHINE SEAM, like the per-spec `live:` line above, and
|
|
1783
|
+
// `/spec-next` reads it to decide which spec it is allowed to build. It answers
|
|
1784
|
+
// in three states rather than two, because "cannot tell" is real here: a branch
|
|
1785
|
+
// switched by hand carries no receipt, so the spec is unknown even though the
|
|
1786
|
+
// checkout is plainly busy. Reporting that as `none` would invite building the
|
|
1787
|
+
// wrong spec; reporting the branch says what is true and lets the caller stop.
|
|
1788
|
+
const inFlight = onBase
|
|
1789
|
+
? 'none — the workbench is free'
|
|
1790
|
+
: receipt && receipt.spec
|
|
1791
|
+
? `${receipt.spec} (branch ${branch || '(detached)'})`
|
|
1792
|
+
: `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
|
|
1793
|
+
|
|
1426
1794
|
process.stdout.write(
|
|
1427
1795
|
'spec-env live:\n' +
|
|
1428
1796
|
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
1797
|
+
` in-flight: ${inFlight}\n` +
|
|
1429
1798
|
` receipt: ${summarizeReceipt(receipt)}\n`,
|
|
1430
1799
|
)
|
|
1431
1800
|
}
|
|
@@ -1513,6 +1882,14 @@ async function run(argv) {
|
|
|
1513
1882
|
|
|
1514
1883
|
const [cmd, ...rest] = argv
|
|
1515
1884
|
|
|
1885
|
+
if (cmd === 'gating') {
|
|
1886
|
+
const [sub, ...gArgs] = rest
|
|
1887
|
+
const gDir = process.cwd()
|
|
1888
|
+
if (sub === 'check') gatingCheck(gDir, gArgs)
|
|
1889
|
+
else process.stdout.write('Usage: skitterspec gating check [spec] [--all] [--json]\n')
|
|
1890
|
+
return
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1516
1893
|
if (cmd === 'spec-env') {
|
|
1517
1894
|
await specEnv(rest)
|
|
1518
1895
|
return
|
|
@@ -1560,11 +1937,24 @@ async function run(argv) {
|
|
|
1560
1937
|
// Fresh repo (or create-missing): isolation defaults OFF; a flag or an
|
|
1561
1938
|
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1562
1939
|
let isolation = opts.isolation === true
|
|
1940
|
+
let workspaceMode = 'worktree'
|
|
1941
|
+
let gating = opts.gating === true
|
|
1563
1942
|
if (interactive && !isExistingSetup(dir)) {
|
|
1564
1943
|
const { promptSetup } = require('./prompts.js')
|
|
1565
|
-
|
|
1944
|
+
const answers = await promptSetup({ isolationSeed: isolation, gatingSeed: gating })
|
|
1945
|
+
isolation = answers.isolation
|
|
1946
|
+
workspaceMode = answers.mode
|
|
1947
|
+
gating = answers.gating
|
|
1566
1948
|
}
|
|
1567
|
-
await init({
|
|
1949
|
+
await init({
|
|
1950
|
+
dir,
|
|
1951
|
+
force: opts.force,
|
|
1952
|
+
claudeMd: opts.claudeMd,
|
|
1953
|
+
mode: 'init',
|
|
1954
|
+
isolation,
|
|
1955
|
+
workspaceMode,
|
|
1956
|
+
gating,
|
|
1957
|
+
})
|
|
1568
1958
|
break
|
|
1569
1959
|
}
|
|
1570
1960
|
case 'update':
|