@skitterbyte/skitterspec 16.7.0 → 16.10.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.
@@ -1,9 +1,9 @@
1
1
  ## Spec workflow
2
2
 
3
- Spec-driven development runs through seven lifecycle skills (plus `/spec-connect`
4
- when isolation is on) — use them so structure and lifecycle stay consistent (see
5
- `.claude/rules/spec-planning.md`). The everyday loop is
6
- **`spec go connect commit complete`**:
3
+ Spec-driven development runs through the lifecycle **skills** below — use them so
4
+ structure and lifecycle stay consistent (see `.claude/rules/spec-planning.md`).
5
+ The everyday loop is **`spec → go → commit → complete`**, with `/spec-connect`
6
+ in the middle when you want to test the spec in a browser.
7
7
 
8
8
  | Skill | Action | Status | Folder |
9
9
  |-------|--------|--------|--------|
@@ -13,8 +13,15 @@ when isolation is on) — use them so structure and lifecycle stay consistent (s
13
13
  | `/spec-go` | Provision the env, bring dev servers up, implement the next phase | `In Progress` | `specs/in-progress/` |
14
14
  | `/spec-complete` | Verify all phases done + tests green; land + tear down | `Complete` | `specs/complete/` |
15
15
  | `/spec-cancel` | Record progress, stamp a reason; tear down | `Cancelled` | `specs/cancelled/` |
16
+ | `/spec-hotfix` | (Hotfix) Fork a worktree from a release tag, red→green, land by tag | `In Progress` | `specs/in-progress/` |
17
+ | `/spec-to-main` | Land the branch on the base mid-spec, without finishing | (unchanged) | (unchanged) |
16
18
  | `/spec-init` | Bootstrap/repair this workflow in a project (idempotent) | — | — |
17
19
 
20
+ **Skills vs commands.** The table is skills — Claude reads them and exercises
21
+ judgment. `/spec-connect` and `/spec-live` are **slash commands** instead: each
22
+ pre-executes one `spec-env` verb and relays it, so only you can run them. A skill
23
+ that wants one will tell you to type it.
24
+
18
25
  Every spec has a **type** (`> **Type:** Feature\|Bug`) and a filename prefix
19
26
  (`feat-<name>` / `bug-<name>`) — never `[BUG]` brackets (glob hazard). Specs use
20
27
  markdown checkboxes (`- [ ]`) for task tracking and are the single source of
@@ -32,12 +39,15 @@ State log.
32
39
  (`skitterspec init --isolation`, or `specs/.core/env.config.json` present),
33
40
  **`/spec-go`** gives every in-progress spec its own git worktree automatically —
34
41
  parallel specs, no stashing, `main` left free — and brings up the project's host
35
- **dev servers** (`env.config` → `dev`) on the spec's ports. **`/spec-connect
36
- <name>`** then exposes that spec on your canonical `localhost` ports so you can
37
- test it at the normal URL (`/spec-connect main` hands them back). Docker is a
42
+ **dev servers** (`env.config` → `dev`) on the spec's ports. The **`/spec-connect <name>`** command then exposes that spec on your canonical
43
+ `localhost` ports so you can test it at the normal URL (`/spec-connect main`
44
+ hands them back); for a code-only spec, **`/spec-live <name>`** reuses the dev
45
+ server you already have running instead. Docker is a
38
46
  **per-spec escalation**: `/spec` sets `> **Stack:** worktree` (default) or
39
47
  `worktree + docker` when the spec touches the DB / stateful services, and only
40
48
  the latter gets a namespaced stack. Teardown is folded into **`/spec-complete`** ·
41
- **`/spec-cancel`**; beneath the skills, `skitterspec spec-env
42
- <up|down|dev|connect|integrate>` is the CLI engine. Independent of lifecycle
49
+ **`/spec-cancel`**; beneath the skills, `skitterspec spec-env` is the CLI engine — `up`, `down`,
50
+ `prune`, `dev`, `connect`, `integrate`, `hotfix`, `live`, `status` and `resolve`.
51
+ Most are planners the skills run; omit the spec name and it uses the worktree you
52
+ are standing in. Independent of lifecycle
43
53
  status; inactive when `env.config.json` is absent.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Expose one spec's dev servers on the canonical localhost ports (or `main` to hand them back)
3
+ argument-hint: "[spec|main]"
4
+ allowed-tools: Bash({{exec}} skitterspec spec-env connect:*)
5
+ disable-model-invocation: true
6
+ ---
7
+ !`{{exec}} skitterspec spec-env connect $ARGUMENTS`
8
+
9
+ Relay the engine output above verbatim. Add nothing and run nothing else.
10
+
11
+ Only if it reports a **canonical port is in use**: say that the main checkout's
12
+ dev server still holds that port, and that stopping it and re-running is the fix
13
+ (the proxy cannot share a port main is bound to).
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: Put one spec live on the already-running dev server — `<spec>` takes it, a bare `take` takes the spec you are on, `main` hands the instance back
3
+ argument-hint: "[<spec> | main | take | release | abort | status]"
4
+ allowed-tools: Bash({{exec}} skitterspec spec-env live:*)
5
+ disable-model-invocation: true
6
+ ---
7
+ !`{{exec}} skitterspec spec-env live $ARGUMENTS`
8
+
9
+ Relay the engine output above verbatim. Add nothing and run nothing else.
10
+
11
+ The engine enforces every refusal itself and prints why — a hotfix, a stateful
12
+ spec (`Stack: worktree + docker`), a branch touching migrations, a dirty tree, a
13
+ rebase conflict, or another spec already holding the instance. Do not re-explain
14
+ or work around any of them.
@@ -148,6 +148,24 @@ no live `env.config.json` was found.
148
148
  "refuseTeardownIfUnpushed": true
149
149
  },
150
150
 
151
+ // What teardown cleans up beyond this machine. `/spec-go` pushes the spec
152
+ // branch when it provisions, so without this a completed spec leaves a merged
153
+ // branch on the remote forever. `deleteRemoteBranch`:
154
+ // "prompt" (default) — plan `git push <remote> --delete <branch>` in its own
155
+ // "confirm with the user first" section; /spec-complete and
156
+ // /spec-cancel ask before running it.
157
+ // "never" — omit it; clean the remote up yourself.
158
+ // "always" — fold it into `run these:` and never ask.
159
+ // Only ever planned for a branch that has LANDED (merged into base, or captured
160
+ // by a hotfix's deploy tag) and whose remote-tracking ref this clone can
161
+ // actually see. Until a branch lands, the remote copy is its only backup, so
162
+ // --force does NOT enable this. A branch pushed from another machine has no
163
+ // local ref here and is simply missed — teardown under-cleans rather than
164
+ // deleting something it cannot see. An unrecognised value means "prompt".
165
+ "teardown": {
166
+ "deleteRemoteBranch": "prompt"
167
+ },
168
+
151
169
  // Live overlay (`spec-env live` / `/spec-live`): test a spec on the already-
152
170
  // running dev server by checking its branch out in the primary checkout.
153
171
  // `migrations` is a list of globs (`**`, `*`, `?`) marking migration files; a
@@ -1,7 +1,7 @@
1
1
  # Spec Planning
2
2
 
3
- Spec-driven development is driven by nine lifecycle skills (plus `/spec-connect`
4
- when isolation is on) — use them rather than hand-rolling specs so the structure
3
+ Spec-driven development is driven by nine lifecycle skills (plus the
4
+ `/spec-connect` and `/spec-live` **commands** when isolation is on) — use them rather than hand-rolling specs so the structure
5
5
  and lifecycle stay consistent. Each sets a status on the spec header
6
6
  (`> **Status:** …`):
7
7
 
@@ -17,6 +17,20 @@ and lifecycle stay consistent. Each sets a status on the spec header
17
17
  | `/spec-cancel` | Record progress, stamp a reason on the header; tear down | `Cancelled` | `specs/cancelled/` |
18
18
  | `/spec-init` | Bootstrap/repair this workflow in a project (idempotent) | — | — |
19
19
 
20
+ **Skills vs commands.** The table above lists **skills** — Claude reads them and
21
+ exercises judgment. `/spec-connect` and `/spec-live` are instead **slash commands**
22
+ (`.claude/commands/`): each pre-executes one `skitterspec spec-env` verb and
23
+ relays its output, so there is no judgment to apply and no model turn spent
24
+ finding one. They are marked `disable-model-invocation`, meaning **only you can
25
+ run them** — a skill that wants one will tell you to type it rather than invoking
26
+ it.
27
+
28
+ `/spec-to-main`, `/spec-status` and `/spec-sync` stay **skills** — each carries
29
+ real judgment (green tests before a land; an MCP fetch and a team-key check; ten
30
+ subcommands) — but they are marked user-only too, since nobody reaches them
31
+ except by typing them. Everything else in the table above stays model-invocable,
32
+ which is what lets `/spec-go` hand off to `/spec-push` as work progresses.
33
+
20
34
  Status flow: `Ready → In Progress → Complete` (or `Cancelled` from any state).
21
35
  `/spec` grills to a **Ready** spec directly — there is no separate grooming
22
36
  command; it writes `Draft` only when open questions are deliberately left.
@@ -41,7 +55,8 @@ your canonical `localhost` ports so you can test it at the normal URL
41
55
  move, header edits, the code) happens on the spec's branch in the worktree; `main`
42
56
  changes only when it merges. Teardown is folded into `/spec-complete` ·
43
57
  `/spec-cancel`. Beneath the skills, `skitterspec spec-env
44
- <up|down|prune|dev|connect|integrate|hotfix>` is the CLI engine. Teardown drops
58
+ <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve>` is the CLI
59
+ engine (omit the spec name and it uses the worktree you are standing in). Teardown drops
45
60
  the finished spec's own test-DB volume; `spec-env prune` additionally reaps
46
61
  **orphaned** volumes left by declined/aborted teardowns, so `/spec-complete` and
47
62
  `/spec-cancel` also sweep orphans (confirm-first). A **hotfix** is the one
@@ -50,7 +65,7 @@ tag and `/spec-complete` lands it via `spec-env hotfix land` (tag + cherry-pick)
50
65
  not a fast-forward. Isolation is **orthogonal to lifecycle status** and inactive
51
66
  when `env.config.json` is absent — every skill then behaves as it does today.
52
67
 
53
- **Live overlay (`/spec-live`) — the light way to test a spec.** `/spec-connect`
68
+ **Live overlay (`/spec-live`, a command) — the light way to test a spec.** `/spec-connect`
54
69
  runs a spec's *own* dev stack and proxies the canonical ports to it (one stack per
55
70
  spec). **Live overlay** instead reuses the one dev server you already have running:
56
71
  `/spec-live <spec>` rebases the branch onto base, frees it from its worktree, and
@@ -53,6 +53,26 @@ Briefly play back the agreed understanding before writing.
53
53
 
54
54
  This skill is for **features**. For bugs, use `/spec-bug` (test-first, red→green).
55
55
 
56
+ ### Write it from the base branch
57
+
58
+ **Check where you are before creating the folder.** A backlog spec belongs on
59
+ the base branch (`main`). If you are on another spec's branch — most likely
60
+ inside its worktree, because a design question came up part-way through
61
+ implementing it — say so before writing, and offer to author the spec from the
62
+ primary checkout instead.
63
+
64
+ It matters for more than tidiness: a spec written inside another spec's worktree
65
+ **physically lives on that branch**. It is not on `main` until that spec lands,
66
+ it is invisible to anyone listing `specs/backlog/` meanwhile, and if that spec is
67
+ cancelled the new spec is cancelled with it. Committing it there also mis-stamps
68
+ the commit's ticket trailer, since that is resolved from the branch (see
69
+ `.claude/rules/commit-trailers.md`, installed with a ticketing provider).
70
+
71
+ **Warn, don't refuse** — doing this deliberately is legitimate, and the user may
72
+ have a reason. If they continue, carry on exactly as normal, and mention that
73
+ the trailer for that commit wants `spec-sync ref <new-spec-name>` rather than the
74
+ bare form.
75
+
56
76
  - **Every spec is a folder** — never a bare file, even for a one-line change:
57
77
  `specs/backlog/feat-<kebab-name>/`. Create it with `mkdir -p`.
58
78
  - The entry point is **always `00-overview.md`** — the index/dashboard for the
@@ -53,8 +53,22 @@ provision from — you'll flesh it out in §4:
53
53
  the worktree path, the opener, and any `in the worktree, run:` bootstrap steps.
54
54
  - Run the printed `git worktree add`. **The worktree forks from `main`'s last
55
55
  commit, so your uncommitted stub doesn't travel with it** — move it across so
56
- `main` is left pristine:
57
- `mv specs/in-progress/bug-<name> <worktreePath>/specs/in-progress/`.
56
+ `main` is left pristine. **Create the destination bucket first:**
57
+
58
+ ```
59
+ mkdir -p <worktreePath>/specs/in-progress
60
+ mv specs/in-progress/bug-<name> <worktreePath>/specs/in-progress/
61
+ ```
62
+
63
+ The `mkdir -p` is not belt-and-braces. Git does not store empty directories,
64
+ so `specs/in-progress/` is **absent** from a fresh worktree whenever that
65
+ bucket happens to be empty on `main` — the common case, since it empties every
66
+ time the last in-progress spec completes. `mv` into a missing destination
67
+ renames your spec folder **to** `specs/in-progress`, silently: the spec's files
68
+ end up one level too high, `00-overview.md` sits where the bucket should be,
69
+ and every later step still appears to work until something cannot find the
70
+ spec. Confirm the result before carrying on — you want
71
+ `<worktreePath>/specs/in-progress/bug-<name>/00-overview.md`.
58
72
  - **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
59
73
  none of the repo's gitignored files (`.env`, local overrides). Run the printed
60
74
  `in the worktree, run:` steps (file seeding, then `setup`) in order, before
@@ -160,6 +174,8 @@ fix narrative and decisions — keep them separate.
160
174
 
161
175
 
162
176
 
177
+
178
+
163
179
  ## 5. Drive to GREEN
164
180
 
165
181
  - Implement the **minimal, root-cause** fix. Match surrounding code; honour all
@@ -81,6 +81,15 @@ directly (the old `/spec-env-down` skill is gone — teardown is folded in here)
81
81
  3. `skitterspec spec-env down <name>` — then execute the printed commands to
82
82
  remove the worktree/stack and free the slot. It respects the teardown guards
83
83
  (won't destroy a dirty/unpushed worktree without `--force`).
84
+
85
+ If the plan prints a `remote branch — confirm with the user first:` section,
86
+ **ask before running that line** — it is a `git push <remote> --delete`, and
87
+ the branch is merged, so it loses nothing. Usually there is nothing to ask:
88
+ a cancelled spec is normally **unlanded**, and the planner deliberately never
89
+ offers the remote delete then, because the pushed branch is the only surviving
90
+ copy of abandoned work. Seeing no such section here is the expected case, not
91
+ a fault — and if the user wants the remote branch gone anyway, that is their
92
+ call to make explicitly, not something to tidy away on their behalf.
84
93
  4. `skitterspec spec-env prune` — reap orphaned test-DB volumes that belong to no
85
94
  live spec (leftovers from declined/aborted teardowns or manual worktree
86
95
  removal). Show the orphan list and, **only on the user's confirmation**, run
@@ -120,7 +120,7 @@ is finished — e.g. to run a later phase in CI or a shared test env? Use
120
120
  back to base, re-isolates it into its worktree, clears the receipt), then prints
121
121
  the normal landing plan. Commit any live fixes to the branch first; it refuses
122
122
  if the primary checkout is dirty, or if a *different* spec holds it (release that
123
- one with `/spec-live main`). Teardown (step 7) is unchanged.
123
+ one by typing `/spec-live main`). Teardown (step 7) is unchanged.
124
124
  **Work-loss abort.** Before it ends the live session, `integrate` checks the
125
125
  work is actually landable and **aborts loudly** rather than finalize a spec
126
126
  having landed nothing. Two cases, both leaving the live session intact:
@@ -176,6 +176,17 @@ worktree and branch are being kept, and go straight to sub-step 4. Mention
176
176
  still respects the guards (won't destroy a dirty, or unpushed-and-unlanded,
177
177
  worktree without `--force`), so if it *does* refuse, relay that and stop
178
178
  rather than reaching for `--force`.
179
+
180
+ **If the plan prints a `remote branch — confirm with the user first:`
181
+ section, ask before running it.** `/spec-go` pushed this branch when it
182
+ provisioned, so the remote copy outlives teardown unless someone deletes it.
183
+ The planner only ever offers this for a branch that has **landed**, so say so
184
+ plainly when you ask — the commits are on the base branch (or captured by the
185
+ deploy tag), so deleting the remote branch loses nothing. On a yes, run the
186
+ printed `git push <remote> --delete <branch>`; on a no, leave it and say the
187
+ remote branch is still there. Never fold it into the `run these:` batch — a
188
+ project that wants it unattended sets `teardown.deleteRemoteBranch: "always"`
189
+ in `env.config.json`, and then it appears in `run these:` instead of here.
179
190
  4. **Reap orphaned test-DB volumes:** run `skitterspec spec-env prune`. It lists
180
191
  Docker volumes in the repo namespace that belong to **no live spec** (no
181
192
  worktree) — leftovers from declined/aborted teardowns, manual
@@ -7,7 +7,8 @@ description: Promote a spec into active development and build the next phase —
7
7
 
8
8
  The "up" button: it promotes the spec, provisions its worktree, brings its host
9
9
  dev servers up on the spec's reserved ports (with your OK), then builds the phase.
10
- Diverting your browser to the spec is a separate explicit step `/spec-connect`.
10
+ Diverting your browser to the spec is a separate explicit step the **user**
11
+ types — `/spec-connect` (a slash command, not a skill you can invoke).
11
12
 
12
13
  ## 1. Identify the target spec
13
14
 
@@ -33,8 +34,8 @@ branch. Instead skip the provisioning bullets and step 2b, leave the spec where
33
34
  it is, and go straight to **step 4**, implementing the phase **in the primary
34
35
  checkout on the branch** — edits and commits there advance the branch, and
35
36
  `/spec-complete` lands them. (`spec-env up` refuses while live and says the same.
36
- To return to an isolated worktree instead, run `/spec-live main` first, then
37
- re-run `/spec-go`.)
37
+ To return to an isolated worktree instead, ask the user to type `/spec-live main`
38
+ first, then re-run `/spec-go`.)
38
39
 
39
40
  **If per-spec isolation is enabled** (`specs/.core/env.config.json` exists), the
40
41
  spec **isn't already live** (the check above), and it doesn't already have a
@@ -121,11 +122,12 @@ runnable — its UI/API on the spec's reserved port block, isolated from `main`.
121
122
  dev process detached on its port, logs to `.spec-env/logs/`, and waits on each
122
123
  `health` check. With no `dev` configured it's a clean no-op; skip this step.
123
124
  - **Diverting your browser is a separate step.** To test the spec at your normal
124
- `localhost` URL, run **`/spec-connect <name>`** (exclusive — it exposes this
125
- spec on the canonical ports; `/spec-connect main` hands them back). `/spec-go`
126
- never seizes the canonical ports on its own. For a **code-only** spec, the
127
- lighter **`/spec-live <name>`** reuses your already-running dev server (a
128
- branch-switch, no second stack) — `/spec-live main` hands it back.
125
+ `localhost` URL, the **user** types **`/spec-connect <name>`** (exclusive — it
126
+ exposes this spec on the canonical ports; `/spec-connect main` hands them back).
127
+ `/spec-go` never seizes the canonical ports on its own. For a **code-only** spec,
128
+ the lighter **`/spec-live <name>`** reuses the already-running dev server (a
129
+ branch-switch, no second stack) — `/spec-live main` hands it back. Both are
130
+ user-only slash commands: tell the user to run one, never try to invoke it.
129
131
 
130
132
  ## 3. Pre-flight — commit prior work
131
133
 
@@ -65,8 +65,22 @@ that header — must exist **before** `spec-env up`:
65
65
  bootstrap steps.
66
66
  - Run the printed `git worktree add`. **The worktree is checked out at the tag,
67
67
  so your uncommitted stub doesn't travel with it** — move it across so `main`
68
- stays pristine:
69
- `mv specs/in-progress/hotfix-<name> <worktreePath>/specs/in-progress/`.
68
+ stays pristine. **Create the destination bucket first:**
69
+
70
+ ```
71
+ mkdir -p <worktreePath>/specs/in-progress
72
+ mv specs/in-progress/hotfix-<name> <worktreePath>/specs/in-progress/
73
+ ```
74
+
75
+ The `mkdir -p` is not belt-and-braces. Git does not store empty directories, so
76
+ `specs/in-progress/` is **absent** from the worktree whenever nothing was in
77
+ progress at that point in history — and here that point is an **old release
78
+ tag**, where it is absent more often than not. `mv` into a missing destination
79
+ renames your spec folder **to** `specs/in-progress`, silently: the spec's files
80
+ end up one level too high, `00-overview.md` sits where the bucket should be, and
81
+ every later step still appears to work until something cannot find the spec.
82
+ Confirm the result before carrying on — you want
83
+ `<worktreePath>/specs/in-progress/hotfix-<name>/00-overview.md`.
70
84
  - **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
71
85
  none of the repo's gitignored files (`.env`, local overrides). Run the printed
72
86
  `in the worktree, run:` steps (file seeding, then setup) in order, before
@@ -171,6 +185,8 @@ narrative and decisions).
171
185
 
172
186
 
173
187
 
188
+
189
+
174
190
  ## 6. Drive to GREEN
175
191
 
176
192
  - Implement the **minimal, root-cause** fix on the branch. Match surrounding code;
@@ -194,7 +210,7 @@ full test result. The spec stays in `in-progress`.
194
210
 
195
211
  - **`/spec-live` is refused for a hotfix** — its branch is built on an old tag, so
196
212
  hot-reloading it onto the running dev server could break the shared instance.
197
- To test it, use `/spec-connect` (its own isolated stack).
213
+ To test it, the user runs `/spec-connect` (its own isolated stack).
198
214
  - Suggest **`/spec-complete`** to land it: it patch-bumps the base tag, tags the
199
215
  hotfix branch **locally** (you push it to deploy), and cherry-picks the fix onto
200
216
  `main`. Add `--also <tag>` at completion to also patch other release lines
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: spec-to-main
3
3
  description: Land an in-progress spec's branch onto main WITHOUT finishing it — rebase + fast-forward so the work reaches main (to run it in CI / a shared test env), while the worktree stays and the spec stays In Progress. Repeatable — land again as you commit more. Targets a spec by name (arg) or the spec in context. Use when the user says "/spec-to-main", "land this on main but keep going", "I need this on main to run tests before finishing", or "merge to main without completing the spec".
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # /spec-to-main — land the branch on main, keep the spec open
@@ -51,7 +52,7 @@ uncommitted changes, offer `/commit` and **stop**; don't auto-commit.
51
52
  back to base, re-isolates it into its worktree, clears the receipt), then prints
52
53
  the landing plan. Commit any live fixes to the branch first; it refuses if the
53
54
  primary checkout is dirty, or if a *different* spec holds it (release that one with
54
- `/spec-live main`).
55
+ `/spec-live main`, which the user types).
55
56
 
56
57
  ## 3. Tests must be green before landing
57
58
 
@@ -22,7 +22,23 @@ if (!existsSync(join(__dirname, '..', 'src'))) {
22
22
 
23
23
  const { run } = require('../src/cli.js')
24
24
 
25
- run(process.argv.slice(2)).catch((err) => {
25
+ // The check above asks whether src/ exists, which is inert in a workspace source
26
+ // package — src/ is committed there. That is exactly where the other half of the
27
+ // problem lives: a source package HAS a runnable bin and src, but its assets/ is
28
+ // PRE-composition (seam markers still literal). Installing from it writes those
29
+ // markers into the user's skills. So ask a second, positive question before any
30
+ // install command runs.
31
+ const argv = process.argv.slice(2)
32
+ if (argv[0] === 'init' || argv[0] === 'update') {
33
+ try {
34
+ require('../src/init.js').assertComposedAssets()
35
+ } catch (err) {
36
+ console.error(`skitterspec: ${err.message}`)
37
+ process.exit(1)
38
+ }
39
+ }
40
+
41
+ run(argv).catch((err) => {
26
42
  console.error(`skitterspec: ${err.message}`)
27
43
  process.exit(1)
28
44
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "16.7.0",
3
+ "version": "16.10.0",
4
4
  "description": "Spec-driven development for Claude Code — a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
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)
@@ -1013,33 +1151,56 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
1013
1151
  // receipt is advisory metadata. `status` is read-only; `take` performs the switch
1014
1152
  // (release/abort land in a later phase).
1015
1153
  async function specEnvLive(dir, config, positional) {
1016
- const action = positional[0] || 'status'
1154
+ const { action, specArg } = liveGrammar(dir, config, positional)
1017
1155
  switch (action) {
1018
1156
  case 'status':
1019
- specEnvLiveStatus(dir, config, positional[1])
1157
+ specEnvLiveStatus(dir, config, specArg)
1020
1158
  break
1021
1159
  case 'take':
1022
- await specEnvLiveTake(dir, config, positional[1])
1160
+ await specEnvLiveTake(dir, config, specArg)
1023
1161
  break
1024
1162
  case 'release':
1025
- await specEnvLiveRelease(dir, config, positional[1])
1163
+ await specEnvLiveRelease(dir, config, specArg)
1026
1164
  break
1027
1165
  case 'abort':
1028
1166
  await specEnvLiveAbort(dir, config)
1029
1167
  break
1030
1168
  default:
1031
- process.stdout.write('Usage: skitterspec spec-env live <take|release|abort|status> [spec]\n')
1169
+ process.stdout.write(
1170
+ 'Usage: skitterspec spec-env live <spec>|<base branch>|<take|release|abort|status> [spec]\n',
1171
+ )
1032
1172
  }
1033
1173
  }
1034
1174
 
1175
+ const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
1176
+
1177
+ // The two front doors every doc names — `/spec-live <spec>` and `/spec-live main`
1178
+ // — translated to verbs. They live here rather than in the command because
1179
+ // `.claude/commands/spec-live.md` relays `$ARGUMENTS` untranslated (it is a
1180
+ // pre-executed script, with no model turn to rewrite them); the skill this
1181
+ // replaced did the translation itself, which is how these forms came to be
1182
+ // documented but unimplemented. `connect` needs no equivalent — its argument was
1183
+ // always spec-shaped (`specArg || 'main'`).
1184
+ //
1185
+ // VERB PRECEDENCE IS DELIBERATE, and so is the order below: the four verbs and
1186
+ // the base branch are matched BEFORE the spec-name fallback, so a spec folder
1187
+ // that happens to be called `status` cannot silently branch-switch the primary
1188
+ // checkout. Such a spec is still reachable — as `live take status`. The literal
1189
+ // `main` is honoured even where the base branch is named something else,
1190
+ // matching `connect main`, so the muscle memory works in either repo.
1191
+ function liveGrammar(dir, config, positional) {
1192
+ const [first, second] = positional
1193
+ if (!first) return { action: 'status', specArg: undefined }
1194
+ if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
1195
+ if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
1196
+ return { action: 'release', specArg: undefined }
1197
+ }
1198
+ return { action: 'take', specArg: first }
1199
+ }
1200
+
1035
1201
  // Take the running instance: rebase the spec's branch onto base, free it from its
1036
1202
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1037
1203
  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
1204
  const spec = resolveSpecWithWorktree(dir, config, specArg)
1044
1205
 
1045
1206
  // Probe the primary checkout's git state (IO stays here; the planner is pure).
@@ -1329,7 +1490,13 @@ async function specEnv(rest) {
1329
1490
  break
1330
1491
  default:
1331
1492
  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',
1493
+ 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n' +
1494
+ ' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
1495
+ ' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
1496
+ ' NOTE connect and live status keep their own meaning for a missing spec:\n' +
1497
+ ' connect disconnects (= main), live status reports on the whole repo.\n' +
1498
+ ' connect and live also take a bare spec name: `live <spec>` takes the\n' +
1499
+ ' instance, `live main` (or your base branch) hands it back.\n',
1333
1500
  )
1334
1501
  }
1335
1502
  }
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
  }
@@ -1,59 +0,0 @@
1
- ---
2
- name: spec-connect
3
- description: Point your local canonical origin (localhost:3000/:8080) at a spec's running dev servers so you can test a worktree's UI/API changes at the normal URL — or `spec-connect main` to hand the ports back to your main checkout. Runs `skitterspec spec-env connect` (a small bundled reverse proxy). Opt-in — needs specs/.core/env.config.json with a `dev` block. Use when the user says "/spec-connect", "test <spec> locally", "point local at <spec>", or "connect to <spec>".
4
- ---
5
-
6
- # /spec-connect — expose one spec on the canonical ports
7
-
8
- Make `http://localhost:<frontPort>` serve a **spec's** warm dev servers instead of
9
- your main checkout's, so you can test a worktree's UI/API at the exact URL you
10
- always use — no bookmark, base-URL, or OAuth-callback changes. **Exclusive:** one
11
- spec is exposed at a time. `spec-connect main` stops the proxy and hands the ports
12
- back to your primary checkout.
13
-
14
- This skill is **opt-in**: it needs `specs/.core/env.config.json` with a `dev`
15
- block (host dev servers + their `frontPort`s). If isolation or `dev` is absent,
16
- say so and stop.
17
-
18
- **Lighter alternative for a code-only spec:** `/spec-live` reuses the dev server
19
- you already have running (it branch-switches the primary checkout) instead of
20
- starting a second stack — no proxy, one process. Prefer it for code-only specs;
21
- use `/spec-connect` when a spec has its own Docker stack, or to run several stacks
22
- in parallel.
23
-
24
- ## 1. Identify the target
25
-
26
- - Use the spec named as an argument. The literal `main` means **disconnect**
27
- (hand the ports back to the primary checkout). Else use the spec **currently in
28
- context**; if unclear, ask.
29
-
30
- ## 2. Make sure the spec's dev servers are running
31
-
32
- `connect` proxies to a spec's dev servers on its reserved port block — it does
33
- **not** start them. If they aren't up yet, start them first:
34
-
35
- ```
36
- skitterspec spec-env dev up <spec>
37
- ```
38
-
39
- (This is automatic under `/spec-go`; run it by hand only when connecting a spec
40
- whose servers you stopped.)
41
-
42
- ## 3. Connect (or disconnect)
43
-
44
- ```
45
- skitterspec spec-env connect <spec> # expose <spec> on the canonical ports
46
- skitterspec spec-env connect main # stop the proxy — main owns the ports
47
- ```
48
-
49
- The engine (re)starts a small bundled Node reverse proxy and **prints** the
50
- canonical URL → spec-port mapping. **If it reports a canonical port is in use**,
51
- your **main dev server still holds it** — stop main on that port, then re-run
52
- (the proxy can't share a port main is bound to). Relay the printed message.
53
-
54
- ## 4. Report
55
-
56
- Echo which spec is now on the canonical ports (and the URLs), or that the proxy
57
- was stopped and main owns them again. Switching to a different spec is just
58
- `spec-connect <other>` — the dev servers stay warm, so it's a near-instant
59
- re-point.
@@ -1,73 +0,0 @@
1
- ---
2
- name: spec-live
3
- description: Test a spec on your already-running dev server by checking its branch out in the primary checkout — no second stack, no proxy. `spec-live <spec>` takes the running instance for that spec; `spec-live main` releases it. Runs `skitterspec spec-env live`. Opt-in — needs specs/.core/env.config.json. Code-only specs; stateful (Docker/migration) specs use /spec-connect. Use when the user says "/spec-live", "go live with <spec>", "take the instance for <spec>", or "test <spec> on the running server".
4
- ---
5
-
6
- # /spec-live — put one spec live on the running instance
7
-
8
- Instead of running a second dev stack for a spec (that's `/spec-connect`), **reuse
9
- the one instance you already have**: rebase the spec's branch onto base, hand it
10
- from its worktree to the **primary checkout**, and let your running dev server
11
- hot-reload it. You test at your normal URL, with one process. The branch that's
12
- checked out in the primary checkout **is** the lock — exactly one spec is live at
13
- a time, and `/spec-live main` hands the instance back.
14
-
15
- This skill is **opt-in**: it needs `specs/.core/env.config.json`. If isolation is
16
- absent, say so and stop.
17
-
18
- **Code-only.** Live overlay refuses a **stateful** spec — one whose `> **Stack:**`
19
- is `worktree + docker`, or whose branch changes migrations (per
20
- `env.config.json` → `live.migrations`). Those keep their isolated stack; use
21
- `/spec-connect` for them. It also **always refuses a `Type: Hotfix` spec** — its
22
- branch is built on an old release tag, so hot-reloading it onto the running dev
23
- server could break the shared instance; test a hotfix with `/spec-connect`. The
24
- engine enforces all of this and prints why.
25
-
26
- ## 1. Identify the target
27
-
28
- - Use the spec named as an argument. The literal `main` means **release** (hand
29
- the instance back to base). Else use the spec **currently in context**; if
30
- unclear, ask.
31
-
32
- ## 2. Make sure a dev server is running
33
-
34
- `live take` **verifies** a dev server is up on your canonical ports and switches
35
- the branch under it — it does **not** start one. If nothing is listening it
36
- refuses; start your dev server first (however you normally run it, or
37
- `skitterspec spec-env dev up <spec>`). (Projects with no `dev` servers configured
38
- have nothing to hot-reload — the switch still happens, with a warning.)
39
-
40
- ## 3. Take (or release)
41
-
42
- ```
43
- skitterspec spec-env live take <spec> # rebase → detach worktree → checkout in primary
44
- skitterspec spec-env live release # hand the instance back to base, re-isolate the branch
45
- skitterspec spec-env live abort # crash recovery (see below)
46
- skitterspec spec-env live status # who's live (branch in the primary checkout + receipt)
47
- ```
48
-
49
- **Take** rebases the branch onto base, frees it from its worktree
50
- (`switch --detach`), checks it out in the primary checkout, then writes a receipt
51
- (`.spec-env/live.json`). Relay its output. **If it reports the rebase hit
52
- conflicts**, it left everything untouched — rebase the branch in its worktree,
53
- resolve, then retry. **If it says a spec already holds the instance**, release it
54
- first. If it warns dependencies changed, restart your dev server after the switch.
55
-
56
- **Release** (`/spec-live main`) is the graceful exit of an unfinished session:
57
- `skitterspec spec-env live release` reads the live spec from the receipt, checks
58
- base back out in the primary checkout, re-attaches the branch to its worktree, and
59
- clears the receipt. Commit any fixes to the branch first — it refuses on a dirty
60
- tree rather than discard them. (To *finish* a live spec instead of releasing it,
61
- use `/spec-complete`, which is live-aware.)
62
-
63
- **Abort** is crash recovery, for when a session died mid-take and left the primary
64
- checkout on a feature branch: `skitterspec spec-env live abort` restores base from
65
- the receipt and re-isolates. It refuses if the primary checkout has uncommitted
66
- changes (it won't discard them) — commit or stash first.
67
-
68
- ## 4. Report
69
-
70
- Echo which spec is now live on the primary checkout (and any warning), that it was
71
- released / recovered, or — for `status` — which branch the primary checkout is on
72
- and whether the instance is free. Fixes you make while live commit straight onto
73
- the spec's branch.