@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.
- package/assets/claude-md-section.md +19 -9
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/core/env.config.md +18 -0
- package/assets/rules/spec-planning.md +19 -4
- package/assets/skills/spec/SKILL.md +20 -0
- package/assets/skills/spec-bug/SKILL.md +18 -2
- package/assets/skills/spec-cancel/SKILL.md +9 -0
- package/assets/skills/spec-complete/SKILL.md +12 -1
- package/assets/skills/spec-go/SKILL.md +10 -8
- package/assets/skills/spec-hotfix/SKILL.md +19 -3
- package/assets/skills/spec-to-main/SKILL.md +2 -1
- package/bin/skitterspec.js +17 -1
- package/package.json +1 -1
- package/src/cli.js +211 -44
- package/src/env/config.js +19 -0
- package/src/env/teardown.js +62 -4
- package/src/init.js +120 -2
- package/assets/skills/spec-connect/SKILL.md +0 -59
- package/assets/skills/spec-live/SKILL.md +0 -73
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
## Spec workflow
|
|
2
2
|
|
|
3
|
-
Spec-driven development runs through
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
125
|
-
spec on the canonical ports; `/spec-connect main` hands them back).
|
|
126
|
-
never seizes the canonical ports on its own. For a **code-only** spec,
|
|
127
|
-
lighter **`/spec-live <name>`** reuses
|
|
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
|
-
|
|
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,
|
|
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
|
|
package/bin/skitterspec.js
CHANGED
|
@@ -22,7 +22,23 @@ if (!existsSync(join(__dirname, '..', 'src'))) {
|
|
|
22
22
|
|
|
23
23
|
const { run } = require('../src/cli.js')
|
|
24
24
|
|
|
25
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
191
|
-
const
|
|
192
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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 =
|
|
1154
|
+
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
1017
1155
|
switch (action) {
|
|
1018
1156
|
case 'status':
|
|
1019
|
-
specEnvLiveStatus(dir, config,
|
|
1157
|
+
specEnvLiveStatus(dir, config, specArg)
|
|
1020
1158
|
break
|
|
1021
1159
|
case 'take':
|
|
1022
|
-
await specEnvLiveTake(dir, config,
|
|
1160
|
+
await specEnvLiveTake(dir, config, specArg)
|
|
1023
1161
|
break
|
|
1024
1162
|
case 'release':
|
|
1025
|
-
await specEnvLiveRelease(dir, config,
|
|
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(
|
|
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
|
}
|
package/src/env/teardown.js
CHANGED
|
@@ -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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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.
|