@skitterbyte/skitterspec-linear 10.5.2 → 10.7.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.
Files changed (34) hide show
  1. package/assets/claude-md-section.md +19 -9
  2. package/assets/commands/spec-connect.md +13 -0
  3. package/assets/commands/spec-live.md +14 -0
  4. package/assets/core/ci-stages.md +110 -0
  5. package/assets/core/env.config.md +18 -0
  6. package/assets/core/linear.config.json.example +3 -0
  7. package/assets/core/linear.config.md +46 -0
  8. package/assets/rules/commit-trailers.md +84 -0
  9. package/assets/rules/spec-planning.md +17 -3
  10. package/assets/skills/spec/SKILL.md +20 -0
  11. package/assets/skills/spec-bug/SKILL.md +40 -2
  12. package/assets/skills/spec-cancel/SKILL.md +9 -0
  13. package/assets/skills/spec-complete/SKILL.md +12 -1
  14. package/assets/skills/spec-go/SKILL.md +60 -28
  15. package/assets/skills/spec-hotfix/SKILL.md +41 -3
  16. package/assets/skills/spec-linear-setup/SKILL.md +38 -2
  17. package/assets/skills/spec-status/SKILL.md +1 -0
  18. package/assets/skills/spec-sync/SKILL.md +27 -2
  19. package/assets/skills/spec-to-main/SKILL.md +2 -1
  20. package/bin/skitterspec-linear.js +10 -0
  21. package/package.json +1 -1
  22. package/src/cli.js +176 -39
  23. package/src/env/config.js +19 -0
  24. package/src/env/teardown.js +62 -4
  25. package/src/init.js +120 -2
  26. package/src/vendor/linear/cli-sync.js +560 -12
  27. package/src/vendor/linear/config.js +91 -1
  28. package/src/vendor/linear/doctor.js +67 -1
  29. package/src/vendor/linear/released.js +164 -0
  30. package/src/vendor/sync-core/index.js +4 -1
  31. package/src/vendor/sync-core/src/compare.js +59 -3
  32. package/src/vendor/sync-core/src/normalize.js +65 -2
  33. package/assets/skills/spec-connect/SKILL.md +0 -59
  34. package/assets/skills/spec-live/SKILL.md +0 -73
package/src/cli.js CHANGED
@@ -185,23 +185,48 @@ async function cleanupReleaseTooling(dir, opts) {
185
185
 
186
186
  // --- spec-env: per-spec isolation engine (Phase 1: status + resolve) --------
187
187
 
188
- // Print provisioned specs, their slots, and port blocks from the registry.
188
+ /**
189
+ * Print what is provisioned: every spec that owns a git worktree, with its slot
190
+ * and port block when it has one.
191
+ *
192
+ * The worktree — not the slot registry — is what "provisioned" means. `specEnvUp`
193
+ * allocates a slot only when `wantsDocker`, so a `Stack: worktree` spec never
194
+ * enters the registry and a project with `docker.enabled: false` has an
195
+ * permanently empty one. Reading the registry alone reported `no provisioned
196
+ * specs` while worktrees were standing.
197
+ *
198
+ * The registry is still read, but only to ANNOTATE a spec that has a slot — it
199
+ * is the authority on port blocks and nothing else.
200
+ *
201
+ * BLIND SPOT: a worktree removed behind git's back (`rm -rf` without
202
+ * `git worktree prune`) stays listed until pruned. That over-reports, which is
203
+ * the harmless direction for a read-only report.
204
+ */
189
205
  function specEnvStatus(dir, config) {
190
- const registry = readRegistry(dir, config)
191
- const names = Object.keys(registry.slots)
192
- if (!names.length) {
206
+ const worktreePaths = liveWorktreePaths(dir)
207
+ const provisioned = allSpecs(dir, config, worktreePaths)
208
+ .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
209
+ // The primary checkout is itself in `git worktree list`; a spec is
210
+ // provisioned only when it has its OWN worktree, separate from it.
211
+ .filter((s) => s.wt !== dir && worktreePaths.has(s.wt))
212
+ .sort((a, b) => a.folder.localeCompare(b.folder))
213
+
214
+ if (!provisioned.length) {
193
215
  process.stdout.write('spec-env: no provisioned specs.\n')
194
216
  return
195
217
  }
218
+
219
+ const registry = readRegistry(dir, config)
196
220
  process.stdout.write('Provisioned specs:\n')
197
- names
198
- .sort((a, b) => registry.slots[a] - registry.slots[b])
199
- .forEach((name) => {
200
- const slot = registry.slots[name]
221
+ for (const { folder, wt } of provisioned) {
222
+ const slot = registry.slots[folder]
223
+ let ports = ''
224
+ if (slot !== undefined) {
201
225
  const off = portOffset(slot, config)
202
- const hi = off + config.docker.portsPerSpec - 1
203
- process.stdout.write(` ${name} slot ${slot} ports ${off}-${hi}\n`)
204
- })
226
+ ports = ` slot ${slot} ports ${off}-${off + config.docker.portsPerSpec - 1}`
227
+ }
228
+ process.stdout.write(` ${folder}${ports}\n ${path.relative(dir, wt) || wt}\n`)
229
+ }
205
230
  }
206
231
 
207
232
  // Plan a provision: allocate the slot, persist the registry, and print the plan
@@ -209,10 +234,6 @@ function specEnvStatus(dir, config) {
209
234
  // opener). This creates no worktree and starts no stack — the caller runs the
210
235
  // printed commands. Keep the output's verb honest about that.
211
236
  function specEnvUp(dir, config, specArg) {
212
- if (!specArg) {
213
- process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
214
- return
215
- }
216
237
  const spec = resolveSpecWithWorktree(dir, config, specArg)
217
238
 
218
239
  // Live-safe: if this spec is already live on the primary checkout (its branch was
@@ -328,7 +349,7 @@ function gitReader(cwd) {
328
349
  // ancestor of it (fully landed), which lets teardown skip the unpushed guard.
329
350
  function worktreeGitState(worktreePath, base) {
330
351
  if (!fs.existsSync(worktreePath)) {
331
- return { dirty: false, unpushed: false, merged: true, reachableFromTag: false }
352
+ return { dirty: false, unpushed: false, merged: true, reachableFromTag: false, remoteBranch: null }
332
353
  }
333
354
  const git = gitReader(worktreePath)
334
355
 
@@ -357,7 +378,41 @@ function worktreeGitState(worktreePath, base) {
357
378
  const pointing = git(['tag', '--points-at', 'HEAD'])
358
379
  const reachableFromTag = pointing !== null && pointing.length > 0
359
380
 
360
- return { dirty, unpushed, merged, reachableFromTag }
381
+ // remoteBranch = the remote-tracking ref for this worktree's branch when one
382
+ // actually exists here (e.g. "origin/feat/thing"), else null. Teardown plans a
383
+ // remote delete off it, so it has to be a ref we can SEE — never an inference
384
+ // from the branch name, and never a bare assumption that `origin` has it.
385
+ //
386
+ // WHAT WOULD FOOL THIS, both left open deliberately:
387
+ // * A STALE ref — the branch was deleted from another clone and this one
388
+ // hasn't pruned. We plan a delete that no-ops: `git push --delete` errors
389
+ // on a branch that isn't there, which is loud, not destructive.
390
+ // * A branch pushed FROM ANOTHER MACHINE has no remote-tracking ref here, so
391
+ // teardown misses it and the remote branch survives. That is the safe
392
+ // direction — under-cleaning. Closing it means `git ls-remote`, which makes
393
+ // every teardown network-dependent for what is cosmetic cleanup. Not done.
394
+ //
395
+ // Upstream first, so a non-`origin` remote is honoured; `--abbrev-ref` gives the
396
+ // short ref, and the verify catches an upstream configured for a ref that is
397
+ // gone. With no upstream (the branch was pushed without `-u`), ask each remote
398
+ // in turn rather than guessing a name.
399
+ let remoteBranch = null
400
+ const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
401
+ if (upstream && git(['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`]) !== null) {
402
+ remoteBranch = upstream
403
+ } else {
404
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'])
405
+ if (branch && branch !== 'HEAD') {
406
+ for (const remote of (git(['remote']) || '').split('\n').map((r) => r.trim()).filter(Boolean)) {
407
+ if (git(['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]) !== null) {
408
+ remoteBranch = `${remote}/${branch}`
409
+ break
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ return { dirty, unpushed, merged, reachableFromTag, remoteBranch }
361
416
  }
362
417
 
363
418
  // A deterministic-enough compact timestamp for backup filenames (CLI-only; the
@@ -376,10 +431,6 @@ function compactTimestamp() {
376
431
  // the shared parent of every spec's worktree and harmless when empty; removing it
377
432
  // would just re-prompt on the next /spec-go (see spec: isolation-trusts-worktree-dir).
378
433
  function specEnvDown(dir, config, specArg, flags) {
379
- if (!specArg) {
380
- process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
381
- return
382
- }
383
434
  const spec = resolveSpecWithWorktree(dir, config, specArg)
384
435
 
385
436
  // A worktree-only spec never held a slot but its worktree still needs removing,
@@ -419,6 +470,15 @@ function specEnvDown(dir, config, specArg, flags) {
419
470
  out.push('')
420
471
  out.push(' run these:')
421
472
  for (const cmd of plan.commands) out.push(` ${cmd}`)
473
+ // Kept out of `run these:` on purpose — everything above is local and
474
+ // reversible-ish, while this reaches a shared remote. The skills ask before
475
+ // running it; a project that never wants to be asked sets
476
+ // `teardown.deleteRemoteBranch: "always"`, which folds it in above instead.
477
+ if (plan.remoteCommands && plan.remoteCommands.length) {
478
+ out.push('')
479
+ out.push(' remote branch — confirm with the user first:')
480
+ for (const cmd of plan.remoteCommands) out.push(` ${cmd}`)
481
+ }
422
482
  process.stdout.write(out.join('\n') + '\n')
423
483
  }
424
484
 
@@ -482,6 +542,77 @@ function liveWorktreePaths(dir) {
482
542
  return paths
483
543
  }
484
544
 
545
+ /**
546
+ * The spec to act on when the caller named none.
547
+ *
548
+ * Two signals, strongest first:
549
+ *
550
+ * 1. **The worktree you are standing in.** A spec-env verb run from inside a
551
+ * spec's worktree means that spec — there is nothing to infer. This is the
552
+ * case that carries the feature in practice: several worktrees at once is
553
+ * the normal shape of this workflow, so "the only one" rarely resolves.
554
+ * 2. **The only spec that has a worktree**, when cwd says nothing (you are in
555
+ * the primary checkout, or somewhere else entirely).
556
+ *
557
+ * `git worktree list` is the authority for both, and deliberately so. Two
558
+ * nearer-looking signals are wrong here:
559
+ *
560
+ * - The **slot registry** covers only Docker specs — `specEnvUp` allocates a
561
+ * slot exclusively when `wantsDocker`, so a `Stack: worktree` spec never
562
+ * appears in it and a project with `docker.enabled: false` has a permanently
563
+ * empty registry. Absence there says nothing about provisioning.
564
+ * - The **`specs/in-progress/` bucket** says a spec is being worked on, not
565
+ * that it has a worktree — and git does not track an empty directory, so the
566
+ * bucket disappears the moment it empties.
567
+ *
568
+ * Three outcomes, never two: resolved → that spec; several candidates and no cwd
569
+ * hint → throw, listing them; none → throw, pointing at /spec-go. *Cannot tell*
570
+ * never becomes a guess.
571
+ *
572
+ * BLIND SPOT: a spec taken live with `/spec-live` has had its branch moved into
573
+ * the primary checkout and its worktree left on a detached HEAD — it still has a
574
+ * worktree, so it is still a candidate, which is correct. What would fool this is
575
+ * a worktree removed behind git's back (`rm -rf` without `git worktree prune`);
576
+ * git keeps listing it as prunable. That over-reports rather than under-reports,
577
+ * so the failure is an ambiguity error, never a wrong spec.
578
+ */
579
+ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
580
+ const worktreePaths = liveWorktreePaths(dir)
581
+ const provisioned = allSpecs(dir, config, worktreePaths)
582
+ .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
583
+ // The primary checkout is itself in `git worktree list`; a spec is
584
+ // provisioned only when it has its OWN worktree, separate from it.
585
+ .filter((s) => s.wt !== dir && worktreePaths.has(s.wt))
586
+ .sort((a, b) => a.folder.localeCompare(b.folder))
587
+
588
+ // 1. Standing inside a spec's worktree names it outright. Deepest match wins,
589
+ // so a nested worktree is not shadowed by an ancestor one.
590
+ let here
591
+ try {
592
+ here = fs.realpathSync(path.resolve(cwd))
593
+ } catch {
594
+ here = path.resolve(cwd)
595
+ }
596
+ const inside = provisioned
597
+ .filter((s) => here === s.wt || here.startsWith(s.wt + path.sep))
598
+ .sort((a, b) => b.wt.length - a.wt.length)[0]
599
+ if (inside) return inside.folder
600
+
601
+ // 2. Otherwise only an unambiguous set answers.
602
+ if (provisioned.length === 1) return provisioned[0].folder
603
+ if (provisioned.length === 0) {
604
+ throw new Error(
605
+ 'no spec given, and no spec has a worktree — name one explicitly, or run ' +
606
+ '/spec-go to provision it.',
607
+ )
608
+ }
609
+ throw new Error(
610
+ `no spec given, and ${provisioned.length} specs have worktrees — name the one ` +
611
+ `you mean, or run this from inside one:\n` +
612
+ provisioned.map((s, i) => ` ${i + 1}. ${s.folder}`).join('\n'),
613
+ )
614
+ }
615
+
485
616
  // Resolve a spec argument the ONE way every spec-env subcommand resolves it:
486
617
  // against the primary checkout first, then the spec's own worktree, then every
487
618
  // other checkout git knows about. An in-progress spec is git-mv'd into
@@ -493,6 +624,10 @@ function liveWorktreePaths(dir) {
493
624
  // coordinate tokens always expand against `dir` (the primary checkout), so the
494
625
  // answer is identical whether the command was run from main or a worktree.
495
626
  function resolveSpecWithWorktree(dir, config, specArg) {
627
+ // Fill in a missing argument first: everything below (starting with
628
+ // path.basename) assumes a string, and every subcommand that reaches here is
629
+ // one where a missing spec was previously a usage error.
630
+ specArg = specArg || soleProvisionedSpec(dir, config)
496
631
  const { slug } = splitPrefix(path.basename(specArg))
497
632
  const { repo, repoSlug } = repoInfo(dir)
498
633
  const wtTokens = { repo, repoSlug, slug }
@@ -604,6 +739,15 @@ function specEnvPrune(dir, config, flags) {
604
739
  out.push('')
605
740
  out.push(' run these:')
606
741
  for (const cmd of plan.commands) out.push(` ${cmd}`)
742
+ // Kept out of `run these:` on purpose — everything above is local and
743
+ // reversible-ish, while this reaches a shared remote. The skills ask before
744
+ // running it; a project that never wants to be asked sets
745
+ // `teardown.deleteRemoteBranch: "always"`, which folds it in above instead.
746
+ if (plan.remoteCommands && plan.remoteCommands.length) {
747
+ out.push('')
748
+ out.push(' remote branch — confirm with the user first:')
749
+ for (const cmd of plan.remoteCommands) out.push(` ${cmd}`)
750
+ }
607
751
  process.stdout.write(out.join('\n') + '\n')
608
752
  }
609
753
 
@@ -611,10 +755,6 @@ function specEnvPrune(dir, config, flags) {
611
755
  // Queries git for the facts, prints the plan / block / no-op. The /spec-complete
612
756
  // skill executes the printed commands (and aborts a conflicting rebase).
613
757
  function specEnvIntegrate(dir, config, specArg) {
614
- if (!specArg) {
615
- process.stdout.write('Usage: skitterspec spec-env integrate <spec>\n')
616
- return
617
- }
618
758
 
619
759
  // `dir` is already anchored on the primary checkout by the dispatch, so it is
620
760
  // both where the spec resolves and the target of the fast-forward — /spec-complete
@@ -734,8 +874,9 @@ function specEnvIntegrate(dir, config, specArg) {
734
874
  function specEnvHotfix(dir, config, positional, flags) {
735
875
  const action = positional[0]
736
876
  const specArg = positional[1]
737
- if (action !== 'land' || !specArg) {
738
- process.stdout.write('Usage: skitterspec spec-env hotfix land <spec> [--also <tag>]...\n')
877
+ // The action must be named; the spec may be omitted (resolved from the registry).
878
+ if (action !== 'land') {
879
+ process.stdout.write('Usage: skitterspec spec-env hotfix land [spec] [--also <tag>]...\n')
739
880
  return
740
881
  }
741
882
 
@@ -815,10 +956,6 @@ function specEnvHotfix(dir, config, positional, flags) {
815
956
 
816
957
  // Print the resolved identity/coordinates for a single spec.
817
958
  function specEnvResolve(dir, config, specArg) {
818
- if (!specArg) {
819
- process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
820
- return
821
- }
822
959
  const r = resolveSpecWithWorktree(dir, config, specArg)
823
960
  process.stdout.write(
824
961
  `spec: ${r.folder} (${r.bucket})\n` +
@@ -836,8 +973,9 @@ function specEnvResolve(dir, config, specArg) {
836
973
  async function specEnvDev(dir, config, positional) {
837
974
  const action = positional[0]
838
975
  const specArg = positional[1]
839
- if ((action !== 'up' && action !== 'down') || !specArg) {
840
- process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
976
+ // The action must be named; the spec may be omitted (resolved from the registry).
977
+ if (action !== 'up' && action !== 'down') {
978
+ process.stdout.write('Usage: skitterspec spec-env dev <up|down> [spec]\n')
841
979
  return
842
980
  }
843
981
  const spec = resolveSpecWithWorktree(dir, config, specArg)
@@ -1035,11 +1173,6 @@ async function specEnvLive(dir, config, positional) {
1035
1173
  // Take the running instance: rebase the spec's branch onto base, free it from its
1036
1174
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1037
1175
  async function specEnvLiveTake(dir, config, specArg) {
1038
- if (!specArg) {
1039
- process.stdout.write('Usage: skitterspec spec-env live take <spec>\n')
1040
- return
1041
- }
1042
-
1043
1176
  const spec = resolveSpecWithWorktree(dir, config, specArg)
1044
1177
 
1045
1178
  // Probe the primary checkout's git state (IO stays here; the planner is pure).
@@ -1329,7 +1462,11 @@ async function specEnv(rest) {
1329
1462
  break
1330
1463
  default:
1331
1464
  process.stdout.write(
1332
- 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n',
1465
+ 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n' +
1466
+ ' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
1467
+ ' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
1468
+ ' 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',
1333
1470
  )
1334
1471
  }
1335
1472
  }
package/src/env/config.js CHANGED
@@ -29,6 +29,7 @@
29
29
  * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
30
30
  * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
31
31
  * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
32
+ * teardown: { deleteRemoteBranch },
32
33
  * live: { migrations: [ "glob", ... ] } // migration globs → `live take`
33
34
  * // refuses a branch that changes them (code-only v1)
34
35
  * hotfix: { bump, cherryPickMain, targets } // `hotfix land`: patch-bump the
@@ -79,6 +80,12 @@ const DEFAULT_CONFIG = Object.freeze({
79
80
  // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
80
81
  baseBranch: '',
81
82
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
83
+ // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
84
+ // `spec-env down` does about the branch `/spec-go` pushed: "prompt" (default)
85
+ // plans the delete in its own confirm-first section for the skill to ask about,
86
+ // "never" omits it, "always" folds it into the run-blind command list. Only ever
87
+ // planned for a LANDED branch — see teardown.js.
88
+ teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
82
89
  // Live overlay (`spec-env live`). `migrations` is a list of globs marking
83
90
  // migration files; a branch that changes any of them is treated as stateful and
84
91
  // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
@@ -109,6 +116,7 @@ function defaults() {
109
116
  branch: { ...DEFAULT_CONFIG.branch },
110
117
  baseBranch: DEFAULT_CONFIG.baseBranch,
111
118
  guards: { ...DEFAULT_CONFIG.guards },
119
+ teardown: { ...DEFAULT_CONFIG.teardown },
112
120
  live: { migrations: [] },
113
121
  hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
114
122
  }
@@ -254,6 +262,17 @@ function mergeConfig(base, parsed) {
254
262
  assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
255
263
  }
256
264
 
265
+ // An unrecognised policy falls through to the default rather than erroring or
266
+ // being taken literally — a typo ("Always", "yes") must not silently become a
267
+ // stronger setting than the author typed, and "prompt" is the one value that
268
+ // cannot act without a human first.
269
+ if (isObject(parsed.teardown)) {
270
+ const policy = parsed.teardown.deleteRemoteBranch
271
+ if (policy === 'prompt' || policy === 'never' || policy === 'always') {
272
+ base.teardown.deleteRemoteBranch = policy
273
+ }
274
+ }
275
+
257
276
  if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
258
277
  base.live.migrations = normalizeFileList(parsed.live.migrations)
259
278
  }
@@ -13,6 +13,12 @@
13
13
  * Volumes are the only destructive action — dropped by default (reclaims disk)
14
14
  * unless `--keep-volumes`, and always backed up first when a `backupCommand` is
15
15
  * configured.
16
+ *
17
+ * `commands` is safe for a caller to run BLIND — that is the property the remote
18
+ * delete must not break. A remote branch delete reaches outside this machine, so
19
+ * it is returned in a separate `remoteCommands` array that the skills confirm
20
+ * with the user before running, and it never enters `commands` unless the
21
+ * project has opted in with `teardown.deleteRemoteBranch: "always"`.
16
22
  */
17
23
 
18
24
  const { expandTokens } = require('./resolve.js')
@@ -21,9 +27,10 @@ const { expandTokens } = require('./resolve.js')
21
27
  * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
22
28
  * @param {object} config normalised env config.
23
29
  * @param {object} flags { keepVolumes, force }
24
- * @param {object} ctx { worktreeState: { dirty, unpushed, merged, reachableFromTag }, timestamp }
25
- * @returns {object} { blocked, reason, commands, backupCommand, backupPath,
26
- * volumesDropped }
30
+ * @param {object} ctx { worktreeState: { dirty, unpushed, merged, reachableFromTag,
31
+ * remoteBranch }, timestamp }
32
+ * @returns {object} { blocked, reason, commands, remoteCommands, backupCommand,
33
+ * backupPath, volumesDropped }
27
34
  */
28
35
  function planDown(spec, config, flags, ctx) {
29
36
  const { worktreeState = {}, timestamp } = ctx || {}
@@ -113,7 +120,57 @@ function planDown(spec, config, flags, ctx) {
113
120
  commands.push(`git branch ${landed ? '-D' : '-d'} ${spec.branch}`)
114
121
  }
115
122
 
116
- return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
123
+ // --- delete the branch on the remote (planned, never run here) ---
124
+ //
125
+ // `/spec-go` pushes the branch at provision time, so a completed spec otherwise
126
+ // leaves a merged branch on the remote forever. Cleaning that up is the goal;
127
+ // doing it safely is the constraint.
128
+ //
129
+ // Gated on `landed` because until the branch is merged (or captured by a tag)
130
+ // the remote copy is the ONLY backup of the work — that is the whole reason
131
+ // `refuseTeardownIfUnpushed` exists, and deleting the remote branch of an
132
+ // unlanded spec would defeat it. `--force` deliberately does NOT enable this:
133
+ // force is for "I accept losing this worktree", not "also reach out and delete
134
+ // the backup". The same `landed` that decides `-D` vs `-d` decides this, so the
135
+ // two can never disagree about whether the commits are recoverable.
136
+ //
137
+ // A null `remoteBranch` plans nothing. An absence is not evidence — the branch
138
+ // may well be on a remote this clone cannot see (pushed from another machine),
139
+ // and the honest answer to "is there a remote branch?" is then "cannot tell",
140
+ // which routes to doing nothing.
141
+ const remoteCommands = []
142
+ const policy = (config.teardown && config.teardown.deleteRemoteBranch) || 'prompt'
143
+ if (spec.branch && landed && worktreeState.remoteBranch && policy !== 'never') {
144
+ // `remoteBranch` is a short ref like "origin/feat/thing" and the branch name
145
+ // itself contains slashes, so the remote is what remains once the exact
146
+ // "/<branch>" suffix is stripped — not the text before the first slash.
147
+ //
148
+ // If it does not end that way the ref maps to a differently-named branch on
149
+ // the remote (a push refspec, or push.default set to something exotic). We
150
+ // cannot tell what to delete, so we plan nothing rather than guess at a
151
+ // branch name on someone's shared remote.
152
+ const suffix = `/${spec.branch}`
153
+ if (worktreeState.remoteBranch.endsWith(suffix)) {
154
+ const remote = worktreeState.remoteBranch.slice(0, -suffix.length)
155
+ if (remote) {
156
+ const cmd = `git push ${remote} --delete ${spec.branch}`
157
+ // "always" is the project saying it never wants to be asked, so the push
158
+ // joins the run-blind list. Everything else keeps it quarantined.
159
+ if (policy === 'always') commands.push(cmd)
160
+ else remoteCommands.push(cmd)
161
+ }
162
+ }
163
+ }
164
+
165
+ return {
166
+ blocked: false,
167
+ reason: null,
168
+ commands,
169
+ remoteCommands,
170
+ backupCommand,
171
+ backupPath,
172
+ volumesDropped,
173
+ }
117
174
  }
118
175
 
119
176
  function blocked(reason) {
@@ -121,6 +178,7 @@ function blocked(reason) {
121
178
  blocked: true,
122
179
  reason,
123
180
  commands: [],
181
+ remoteCommands: [],
124
182
  backupCommand: null,
125
183
  backupPath: null,
126
184
  volumesDropped: false,
package/src/init.js CHANGED
@@ -23,6 +23,21 @@ function listSkills() {
23
23
  .sort()
24
24
  }
25
25
 
26
+ // Slash commands shipped as `assets/commands/*.md`, installed to
27
+ // `.claude/commands/`. Discovered from the bundled tree exactly like skills, so
28
+ // each distribution installs precisely what it ships.
29
+ function listCommands() {
30
+ const dir = path.join(ASSETS, 'commands')
31
+ try {
32
+ return fs
33
+ .readdirSync(dir)
34
+ .filter((f) => f.endsWith('.md'))
35
+ .sort()
36
+ } catch {
37
+ return [] // a distribution may ship no commands
38
+ }
39
+ }
40
+
26
41
  function listRules() {
27
42
  return fs
28
43
  .readdirSync(path.join(ASSETS, 'rules'))
@@ -42,6 +57,8 @@ function listCoreTemplates() {
42
57
 
43
58
  const SKILLS = listSkills()
44
59
 
60
+ const COMMANDS = listCommands()
61
+
45
62
  const RULES = listRules()
46
63
 
47
64
  const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
@@ -50,6 +67,89 @@ const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled'
50
67
  // env.config isolation templates; a provider superset also ships its own).
51
68
  const CORE_FILES = listCoreTemplates()
52
69
 
70
+ // The CLI is a local devDependency and never on PATH, so a command file that
71
+ // pre-executes it must carry a literal, working invocation. Detect the runner
72
+ // from the lockfile and bake it in at write time.
73
+ //
74
+ // The lockfile is a POSITIVE signal — a file that must be present for the answer
75
+ // to be yes — rather than an absence. When none is found we do not guess a
76
+ // package manager we have no evidence for; `npx` is the fallback because it is
77
+ // the one runner that works across all three installs.
78
+ const PACKAGE_MANAGERS = [
79
+ ['pnpm-lock.yaml', 'pnpm exec'],
80
+ ['yarn.lock', 'yarn'],
81
+ ['package-lock.json', 'npx'],
82
+ ['bun.lockb', 'bunx'],
83
+ ]
84
+
85
+ function detectPackageManager(dir) {
86
+ for (const [lockfile, exec] of PACKAGE_MANAGERS) {
87
+ if (fs.existsSync(path.join(dir, lockfile))) return exec
88
+ }
89
+ return 'npx'
90
+ }
91
+
92
+ // Fill a command file's `{{exec}}` placeholders. Kept a pure function of
93
+ // (content, dir) so `managedTargets` can compare against exactly what
94
+ // `installCommands` would write — otherwise every install would hash as
95
+ // customized on the next run.
96
+ function renderCommand(content, dir) {
97
+ return content.split('{{exec}}').join(detectPackageManager(dir))
98
+ }
99
+
100
+ // --- composed-assets guard -------------------------------------------------
101
+ //
102
+ // A source package's `assets/` is PRE-composition: `<!-- seam:NAME -->` is still
103
+ // literal text that scripts/build-dist.js replaces — with a provider's fragment
104
+ // for the provider distribution, with nothing for the base. Only a built
105
+ // distribution's assets are installable.
106
+ //
107
+ // This is a POSITIVE signal, not an absence: a seam marker must be **present**
108
+ // for the guard to fire, so it cannot misfire on a tree it simply failed to read.
109
+ // And it refuses rather than repairing — installing the wrong thing is the
110
+ // expensive mistake here. In a dev-linked checkout the installed skills are
111
+ // symlinks to the composed distribution, so a `--force` install would write
112
+ // these markers straight through the link and into the built assets.
113
+ const SEAM_RE = /<!--\s*seam:[A-Za-z0-9_-]+\s*-->/
114
+
115
+ function assetFiles(dir) {
116
+ const out = []
117
+ const walk = (d) => {
118
+ let entries
119
+ try {
120
+ entries = fs.readdirSync(d, { withFileTypes: true })
121
+ } catch {
122
+ return // a distribution need not ship every asset kind
123
+ }
124
+ for (const e of entries) {
125
+ const p = path.join(d, e.name)
126
+ if (e.isDirectory()) walk(p)
127
+ else if (e.name.endsWith('.md')) out.push(p)
128
+ }
129
+ }
130
+ walk(dir)
131
+ return out
132
+ }
133
+
134
+ // Throws when this package's assets are uncomposed. Called from the **bins** —
135
+ // the outermost boundary, and the only way a real install happens — never from
136
+ // init()/resync()/run(), which the unit tests legitimately drive against this
137
+ // source tree. Guarding any deeper would make the library untestable while
138
+ // protecting nothing extra.
139
+ function assertComposedAssets() {
140
+ const offenders = assetFiles(ASSETS)
141
+ .filter((f) => SEAM_RE.test(fs.readFileSync(f, 'utf8')))
142
+ .map((f) => path.relative(ASSETS, f))
143
+ if (!offenders.length) return
144
+ throw new Error(
145
+ `refusing to install: this package's assets are uncomposed (${offenders.length} ` +
146
+ `file(s) still carry a <!-- seam:… --> marker, e.g. ${offenders[0]}).\n` +
147
+ ' These are a workspace source package\'s assets, not a distribution\'s. ' +
148
+ 'Run "npm run build" in the skitterspec repo, then run the command again ' +
149
+ 'from the built distribution.',
150
+ )
151
+ }
152
+
53
153
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
54
154
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
55
155
 
@@ -99,13 +199,15 @@ function sha1(content) {
99
199
  // the bundled content that ships in this distribution's assets.
100
200
  function managedTargets(dir) {
101
201
  const out = []
102
- const add = (assetRel, targetAbs) =>
202
+ const add = (assetRel, targetAbs, render = (c) => c) =>
103
203
  out.push({
104
204
  relPath: rel(dir, targetAbs),
105
205
  abs: targetAbs,
106
- bundled: fs.readFileSync(path.join(ASSETS, assetRel), 'utf8'),
206
+ bundled: render(fs.readFileSync(path.join(ASSETS, assetRel), 'utf8'), dir),
107
207
  })
108
208
  for (const name of SKILLS) add(path.join('skills', name, 'SKILL.md'), path.join(dir, '.claude', 'skills', name, 'SKILL.md'))
209
+ for (const name of COMMANDS)
210
+ add(path.join('commands', name), path.join(dir, '.claude', 'commands', name), renderCommand)
109
211
  for (const name of RULES) add(path.join('rules', name), path.join(dir, '.claude', 'rules', name))
110
212
  for (const asset of CORE_FILES) add(asset, path.join(dir, 'specs', '.core', path.basename(asset)))
111
213
  return out
@@ -230,6 +332,16 @@ function installSkills(dir, opts) {
230
332
  }
231
333
  }
232
334
 
335
+ function installCommands(dir, opts) {
336
+ for (const name of COMMANDS) {
337
+ const content = renderCommand(
338
+ fs.readFileSync(path.join(ASSETS, 'commands', name), 'utf8'),
339
+ dir,
340
+ )
341
+ writeFile(dir, path.join(dir, '.claude', 'commands', name), content, opts)
342
+ }
343
+ }
344
+
233
345
  function installRule(dir, opts) {
234
346
  for (const name of RULES) {
235
347
  copyAsset(
@@ -519,6 +631,7 @@ function reset(dir, { claudeMd = true } = {}) {
519
631
  }
520
632
  if (claudeMd) stripClaudeMdSection(dir)
521
633
  installSkills(dir, { force: true })
634
+ installCommands(dir, { force: true })
522
635
  installRule(dir, { force: true })
523
636
  installFolders(dir)
524
637
  removeRetiredFiles(dir)
@@ -597,6 +710,7 @@ async function init({ dir, force, claudeMd, mode, isolation }) {
597
710
  resetReport()
598
711
 
599
712
  installSkills(dir, { force })
713
+ installCommands(dir, { force })
600
714
  installRule(dir, { force })
601
715
  installFolders(dir)
602
716
  removeRetiredFiles(dir)
@@ -615,6 +729,7 @@ async function init({ dir, force, claudeMd, mode, isolation }) {
615
729
  module.exports = {
616
730
  init,
617
731
  SKILLS,
732
+ COMMANDS,
618
733
  RULES,
619
734
  SPEC_FOLDERS,
620
735
  MANIFEST_FILE,
@@ -627,4 +742,7 @@ module.exports = {
627
742
  resync,
628
743
  reset,
629
744
  assertSafeToDelete,
745
+ assertComposedAssets,
746
+ detectPackageManager,
747
+ renderCommand,
630
748
  }