@skitterbyte/skitterspec 16.10.0 → 17.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +36 -0
- package/README.md +3 -3
- package/assets/claude-md-section.md +13 -51
- package/assets/core/env.config.json.example +4 -1
- package/assets/core/env.config.md +26 -6
- package/assets/rules/spec-planning.md +28 -9
- package/assets/skills/spec/SKILL.md +13 -8
- package/assets/skills/spec-bug/SKILL.md +9 -7
- package/assets/skills/spec-cancel/SKILL.md +12 -0
- package/assets/skills/spec-complete/SKILL.md +26 -5
- package/assets/skills/spec-hotfix/SKILL.md +8 -6
- package/assets/skills/spec-init/SKILL.md +16 -6
- package/assets/skills/spec-next/SKILL.md +98 -0
- package/assets/skills/spec-review/SKILL.md +5 -5
- package/assets/skills/spec-start/SKILL.md +146 -0
- package/assets/skills/spec-to-main/SKILL.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +198 -14
- package/src/env/config.js +24 -1
- package/src/env/integrate.js +61 -1
- package/src/env/live.js +27 -3
- package/src/env/provision.js +58 -1
- package/src/env/teardown.js +41 -3
- package/src/init.js +21 -12
- package/src/prompts.js +31 -3
- package/assets/skills/spec-go/SKILL.md +0 -190
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec-next
|
|
3
|
+
description: Build the next unfinished phase of the spec currently in flight on this checkout — pre-flight, implement with tests, record progress and refresh the tracker. Refuses when no spec is in flight rather than guessing one. Use when the user says "/spec-next", "build the next phase", "continue the spec", or "carry on with this spec".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /spec-next — build the next phase of the spec in flight
|
|
7
|
+
|
|
8
|
+
It assumes the workbench is already set up: a spec is **in flight** on this
|
|
9
|
+
checkout, and this skill implements its next unfinished phase. Putting a spec in
|
|
10
|
+
flight — provisioning, moving it to `in-progress`, getting its branch here — is
|
|
11
|
+
`/spec-start`'s job. Re-run this per phase until the spec is done, then
|
|
12
|
+
`/spec-complete`.
|
|
13
|
+
|
|
14
|
+
## 1. Identify the spec in flight
|
|
15
|
+
|
|
16
|
+
Resolve **in this order**, and stop at the first that answers:
|
|
17
|
+
|
|
18
|
+
1. **The live spec of this checkout** — run
|
|
19
|
+
`skitterspec spec-env live status` and read its `live:` line. `live: yes`
|
|
20
|
+
names the spec whose branch is checked out here; that is the one to build.
|
|
21
|
+
2. **The worktree you are standing in** — if this session's cwd is inside a
|
|
22
|
+
spec's worktree, that spec is in flight *for this session*. This is the
|
|
23
|
+
manual-parallel path: several specs may be provisioned, and a terminal tab
|
|
24
|
+
opened in one is its own workbench.
|
|
25
|
+
3. **The current branch, in `checkout` mode** — no worktrees exist, so the
|
|
26
|
+
branch the checkout is on names the spec.
|
|
27
|
+
|
|
28
|
+
**If none answers, refuse and stop:**
|
|
29
|
+
`no spec in flight — run /spec-start <name> to put one on this checkout`.
|
|
30
|
+
|
|
31
|
+
**Never fall back to the spec "in context".** A spec discussed in conversation
|
|
32
|
+
is not a spec in flight, and this skill writes real code: building the wrong
|
|
33
|
+
spec's phase produces commits on a branch nobody asked for. The refusal is
|
|
34
|
+
cheap; the mistake is not.
|
|
35
|
+
|
|
36
|
+
A **name argument** is accepted, but it must *match* the spec in flight — it
|
|
37
|
+
narrows a re-run, it does not select a different spec. A mismatch refuses,
|
|
38
|
+
naming both.
|
|
39
|
+
|
|
40
|
+
## 2. Pre-flight — commit prior work
|
|
41
|
+
|
|
42
|
+
Before writing any code for this phase, get the workspace clean:
|
|
43
|
+
|
|
44
|
+
- **Confirm the last-worked phase is committed.** Run `git status` and
|
|
45
|
+
`git log --oneline -5`. The most recently *implemented* phase (not necessarily
|
|
46
|
+
the numerically previous one) should already be committed. If prior-phase work
|
|
47
|
+
is still uncommitted, **stop and suggest committing it first** (e.g. via
|
|
48
|
+
`/commit`) so each phase lands as its own reviewable commit — don't build the
|
|
49
|
+
next phase on top of an uncommitted one. (Skip if this is the first phase —
|
|
50
|
+
there's nothing prior to commit.)
|
|
51
|
+
|
|
52
|
+
## 3. Implement the phase
|
|
53
|
+
|
|
54
|
+
Identify the **first unfinished phase** from the `00-overview.md` phase index,
|
|
55
|
+
then open its phase file (`0N-<slug>.md`) — that file holds the tasks. Mark it
|
|
56
|
+
started: set the phase-file heading to `🔄` and its `> **Status:**` to
|
|
57
|
+
`In progress`, and flip the matching row in the overview phase index to `🔄`.
|
|
58
|
+
|
|
59
|
+
**Then sync with the tracker (only if a provider is installed).** The phase has
|
|
60
|
+
just changed state, so refresh the mirror before the build starts — that is what
|
|
61
|
+
makes the phase show as in progress *while* it is being built rather than only
|
|
62
|
+
once it is over. Without a provider this is a no-op and nothing below changes.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Then build it, following the project rules in `.claude/rules/*.md` and `CLAUDE.md`:
|
|
67
|
+
|
|
68
|
+
- Work task by task through the phase file. Make focused edits that match
|
|
69
|
+
surrounding code.
|
|
70
|
+
- Honour the project's conventions (see `.claude/rules/spec-planning.md` and the
|
|
71
|
+
rules it links).
|
|
72
|
+
- **Tests are part of the phase, not after it.** Create/extend tests for the
|
|
73
|
+
work, then run the project's typecheck and test commands. Do not declare the
|
|
74
|
+
phase done until green.
|
|
75
|
+
- Never hardcode dates in tests; never run destructive commands against a real
|
|
76
|
+
database — use the project's test database only.
|
|
77
|
+
|
|
78
|
+
## 4. Record progress
|
|
79
|
+
|
|
80
|
+
- In the **phase file**: tick completed tasks (`- [x]`), flip its heading to `✅`,
|
|
81
|
+
and set its `> **Status:**` to `Done`.
|
|
82
|
+
- In **`00-overview.md`**: flip the matching phase-index row to `✅`.
|
|
83
|
+
- If anything changed from the plan (a decision, a deviation, a discovered
|
|
84
|
+
constraint), add a dated **Changelog** entry in `00-overview.md`.
|
|
85
|
+
- If new work surfaced, add it as tasks to the appropriate phase file (or add a
|
|
86
|
+
new phase file + index row) rather than doing it silently.
|
|
87
|
+
|
|
88
|
+
**Then refresh the mirror (only if a provider is installed).** The phase is done
|
|
89
|
+
in the repo now; leaving the tracker to catch up at `/spec-complete` is what makes
|
|
90
|
+
a mirror lag a whole spec behind. Without a provider this is a no-op.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## 5. Report
|
|
95
|
+
|
|
96
|
+
Summarise what was implemented, the test result (quote failures if any), and
|
|
97
|
+
which phase is next. Do **not** `git commit` unless the user asks — finish,
|
|
98
|
+
verify, and wait.
|
|
@@ -8,7 +8,7 @@ description: Re-validate an existing spec against the current codebase — detec
|
|
|
8
8
|
Specs rot: the code moves on while a spec sits in the backlog or pauses
|
|
9
9
|
mid-build. This skill re-validates a spec against the **current** code and
|
|
10
10
|
rewrites the stale parts so it's safe to act on. It plans only — it does not
|
|
11
|
-
implement anything (that's `/spec-
|
|
11
|
+
implement anything (that's `/spec-next`).
|
|
12
12
|
|
|
13
13
|
## 1. Identify the target spec
|
|
14
14
|
|
|
@@ -48,9 +48,9 @@ trust the spec's own wording — verify:
|
|
|
48
48
|
|
|
49
49
|
Where the drift forces a choice (the old approach no longer fits, a referenced
|
|
50
50
|
thing is gone, scope is now ambiguous), grill the user like `/spec` Phase A —
|
|
51
|
-
**
|
|
52
|
-
the drift. Don't re-litigate decisions that still hold. If you can
|
|
53
|
-
reading the code, do that instead of asking.
|
|
51
|
+
**batching independent questions, each with a recommended answer** — but scoped
|
|
52
|
+
tightly to the drift. Don't re-litigate decisions that still hold. If you can
|
|
53
|
+
resolve it by reading the code, do that instead of asking.
|
|
54
54
|
|
|
55
55
|
## 4. Update the spec
|
|
56
56
|
|
|
@@ -87,5 +87,5 @@ reading the code, do that instead of asking.
|
|
|
87
87
|
## 5. Report
|
|
88
88
|
|
|
89
89
|
Summarise the drift found, what you changed, any questions still open, and
|
|
90
|
-
whether the spec is now safe to `/spec-
|
|
90
|
+
whether the spec is now safe to `/spec-start` (or should drop back to `Draft` until
|
|
91
91
|
the open questions are resolved). Do **not** `git commit` unless the user asks.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec-start
|
|
3
|
+
description: Put a spec in flight on this checkout — provision its branch, move it to in-progress, refresh the tracker, then build phase 1. Refuses unless the checkout is on the base branch with nothing already in flight, so it never parks or swaps someone's unfinished work. Use when the user says "/spec-start", "start this spec", or "begin implementing <spec>".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /spec-start — put a spec in flight
|
|
7
|
+
|
|
8
|
+
One checkout, one spec in flight. This skill is how a spec gets there:
|
|
9
|
+
provision, move it to `in-progress`, refresh the tracker, then hand straight on
|
|
10
|
+
to `/spec-next` for phase 1. Continuing a spec afterwards is `/spec-next`;
|
|
11
|
+
finishing it is `/spec-complete`.
|
|
12
|
+
|
|
13
|
+
## 1. The gate — refuse unless the workbench is free
|
|
14
|
+
|
|
15
|
+
**Check this first, before resolving anything or touching a file.** In
|
|
16
|
+
`worktree` mode run `skitterspec spec-env live status`; in `checkout` mode read
|
|
17
|
+
the current branch. The workbench must be:
|
|
18
|
+
|
|
19
|
+
- **on the base branch** (`main`, or the configured `baseBranch`), and
|
|
20
|
+
- **clean** — no uncommitted changes.
|
|
21
|
+
|
|
22
|
+
**If it isn't, relay what is in flight and stop.** Name the spec holding the
|
|
23
|
+
checkout and the three ways out, then end your turn:
|
|
24
|
+
|
|
25
|
+
- **`/spec-complete`** — it's finished; land it and free the workbench.
|
|
26
|
+
- **`/spec-cancel`** — it isn't wanted; record why and free the workbench.
|
|
27
|
+
- **`/spec-live main`** *(worktree mode)* — park it: the branch goes back to its
|
|
28
|
+
worktree and stays exactly as it is, ready to resume later.
|
|
29
|
+
|
|
30
|
+
**Never get past the gate yourself.** Do not stash, do not commit on the
|
|
31
|
+
operator's behalf, do not `/spec-live main` for them, do not switch branches. An
|
|
32
|
+
uncommitted tree, a half-built phase and a rebase are each a decision someone
|
|
33
|
+
must make deliberately — and the cost of guessing is another spec's work moved
|
|
34
|
+
without its author asking. A refusal costs one command; the alternative can cost
|
|
35
|
+
an afternoon.
|
|
36
|
+
|
|
37
|
+
A dirty tree is refused *with the same words whatever the cause*: the gate does
|
|
38
|
+
not try to judge whether the changes look important.
|
|
39
|
+
|
|
40
|
+
## 2. Identify the spec
|
|
41
|
+
|
|
42
|
+
- Use the name/path argument when given.
|
|
43
|
+
- Otherwise use the spec **in context** (the one just created or discussed).
|
|
44
|
+
Unlike `/spec-next`, that fallback is safe here: this skill starts nothing
|
|
45
|
+
without the gate above passing, and a wrong guess is caught by the operator
|
|
46
|
+
before any code is written.
|
|
47
|
+
- Locate it under `specs/` — `specs/backlog/` first, then the other buckets. A
|
|
48
|
+
spec is a `<name>/` folder whose entry point is `00-overview.md`, with one
|
|
49
|
+
file per phase beside it (`01-<slug>.md`, `02-…`). Legacy specs may be a bare
|
|
50
|
+
`<name>.md`, or a `00-overview.md` with inline phases — handle those too.
|
|
51
|
+
- A spec already in `specs/in-progress/` was started before. If its branch is
|
|
52
|
+
parked in a worktree, this skill brings it back into flight; say so rather
|
|
53
|
+
than reporting a fresh start.
|
|
54
|
+
|
|
55
|
+
## 3. Put its branch in this checkout
|
|
56
|
+
|
|
57
|
+
**Read `mode` from `specs/.core/env.config.json`** (default `worktree`).
|
|
58
|
+
|
|
59
|
+
### `worktree` mode
|
|
60
|
+
|
|
61
|
+
1. **Provision.** Run `skitterspec spec-env up <name>` — a planner, so run the
|
|
62
|
+
`to provision, run:` commands it prints and confirm they succeeded. Then run
|
|
63
|
+
its **`then, in the worktree, run:`** steps in order (file seeding, then
|
|
64
|
+
`setup`): a fresh worktree has no dependencies and none of the repo's
|
|
65
|
+
gitignored files, so hooks, typechecks and tests fail until they are there.
|
|
66
|
+
2. **Bring the branch here.** Tell the user to type **`/spec-live <name>`** — it
|
|
67
|
+
rebases the branch, frees it from the worktree and checks it out in this
|
|
68
|
+
checkout, which is what makes this session the workbench. It is a user-only
|
|
69
|
+
command, so you cannot run it: print it, end your turn, and pick up at step 4
|
|
70
|
+
when they re-run `/spec-start`.
|
|
71
|
+
**Already here?** If the live check in step 1 showed this spec live, or the
|
|
72
|
+
branch is already checked out, the move is done — carry straight on.
|
|
73
|
+
3. **A spec the live overlay refuses** — a hotfix, a stateful spec
|
|
74
|
+
(`Stack: worktree + docker`), or a branch touching migrations — **parks
|
|
75
|
+
instead.** Do the housekeeping in step 4 with `git -C <worktreePath>`, run
|
|
76
|
+
`open.command` if configured, print the worktree path, and say to run
|
|
77
|
+
`/spec-next` from a session there. Relay the engine's refusal reason as it
|
|
78
|
+
printed it; those guards protect a shared dev instance and are not yours to
|
|
79
|
+
weaken.
|
|
80
|
+
**Trust the worktree first.** `spec-env up` wrote the printed `trusted:` root
|
|
81
|
+
into `.claude/settings.local.json`, but that file will not hot-reload in this
|
|
82
|
+
session — run `/add-dir <trusted root>` before editing into the worktree, or
|
|
83
|
+
the first write prompts.
|
|
84
|
+
|
|
85
|
+
### `checkout` mode
|
|
86
|
+
|
|
87
|
+
Run `skitterspec spec-env up <name>` and the single `git switch` it prints.
|
|
88
|
+
There is no worktree, no bootstrap, no live step — the checkout is already the
|
|
89
|
+
workbench. Its planner enforces the same gate from the engine side, so relay any
|
|
90
|
+
refusal and stop.
|
|
91
|
+
|
|
92
|
+
## 4. Move the spec into development
|
|
93
|
+
|
|
94
|
+
On the branch, in this checkout (or via `git -C <worktreePath>` for a parked
|
|
95
|
+
spec):
|
|
96
|
+
|
|
97
|
+
- `git mv "specs/backlog/<name>" "specs/in-progress/<name>"` if it isn't there
|
|
98
|
+
already (`mkdir -p specs/in-progress` first). Use `git mv` to keep history.
|
|
99
|
+
- Set the **Status** header: `> **Status:** In Progress — Phase 1 (started <YYYY-MM-DD>)`.
|
|
100
|
+
- Set **Developer** if it is still `—` (`git config user.name`).
|
|
101
|
+
- Append a **State log** row: `| <YYYY-MM-DD> | In Progress | in-progress | <git user.name> |`.
|
|
102
|
+
- **Commit it, and push the branch.** One commit, the spec's own — it records the
|
|
103
|
+
in-progress state for everyone and fires the tracker's automation. Do this
|
|
104
|
+
*before* the tracker refresh below, so the snapshot that refresh writes is
|
|
105
|
+
swept up by the phase's own commit rather than left dirty.
|
|
106
|
+
|
|
107
|
+
A spec ideally arrives `Ready` from `/spec`; a `Draft` works too — sanity-check
|
|
108
|
+
it is well-formed first.
|
|
109
|
+
|
|
110
|
+
## 5. Bring the spec's dev servers up — confirm before heavy steps
|
|
111
|
+
|
|
112
|
+
**Only when the project configures host dev servers** (`env.config.json` → a
|
|
113
|
+
non-empty `dev` array). Show what will start — the commands, the ports, any
|
|
114
|
+
Docker stack — and get a yes; on **`--plan`**, print it and stop. On
|
|
115
|
+
confirmation run `skitterspec spec-env dev up <name>`. With none configured this
|
|
116
|
+
is a clean no-op.
|
|
117
|
+
|
|
118
|
+
To reach the spec at your normal `localhost` URL afterwards, the **user** types
|
|
119
|
+
**`/spec-connect <name>`** (`/spec-connect main` hands the ports back). Never
|
|
120
|
+
invoke it yourself.
|
|
121
|
+
|
|
122
|
+
## 6. Build phase 1
|
|
123
|
+
|
|
124
|
+
**Carry straight on into `/spec-next`** in this session: it marks phase 1
|
|
125
|
+
started, refreshes the mirror again, builds it with tests and reports. Do not
|
|
126
|
+
stop and ask the operator to run it — the workbench is set up and they asked to
|
|
127
|
+
start the spec.
|
|
128
|
+
|
|
129
|
+
## Opt-outs
|
|
130
|
+
|
|
131
|
+
- **`--plan`** — print the provisioning and dev-server plan, change nothing.
|
|
132
|
+
- **`--no-worktree`** — skip provisioning and build on the current branch. Warn
|
|
133
|
+
that the work lands wherever you are (usually the base branch); reserve it for
|
|
134
|
+
a trivial change or an explicit request.
|
|
135
|
+
|
|
136
|
+
There is no `--here`: `/spec-start` **is** here. It puts the branch in the
|
|
137
|
+
checkout you are in, which is what the old opt-out was reaching for.
|
|
138
|
+
|
|
139
|
+
## Why there is no tracker seam here
|
|
140
|
+
|
|
141
|
+
This skill creates no spec and mints no issue, so it has nothing to link — the
|
|
142
|
+
intake and picker steps belong to `/spec`, `/spec-bug` and `/spec-hotfix`. The
|
|
143
|
+
state change it *does* make (the spec moving to `in-progress`) is mirrored by
|
|
144
|
+
the refresh `/spec-next` runs the moment it starts, which pushes the issue state
|
|
145
|
+
and the phase states together. Adding a push here would send the same thing
|
|
146
|
+
twice, one commit apart.
|
|
@@ -15,7 +15,7 @@ as you add commits.
|
|
|
15
15
|
Use it when a later phase can only be done *after* the current work is on `main` —
|
|
16
16
|
e.g. it needs to run in CI, a deploy pipeline, or a shared test environment that
|
|
17
17
|
builds from `main`. Land what you have, run that step, then come back and finish
|
|
18
|
-
the remaining phases with `/spec-
|
|
18
|
+
the remaining phases with `/spec-next` and eventually `/spec-complete`.
|
|
19
19
|
|
|
20
20
|
It reuses the **same engine** as `/spec-complete`'s landing (`spec-env integrate`
|
|
21
21
|
— rebase + fast-forward), so it produces identical linear history. Because a
|
|
@@ -26,9 +26,11 @@ repeatable**: new commits put the branch ahead of base again, and you can run
|
|
|
26
26
|
## 0. Preconditions — when this applies
|
|
27
27
|
|
|
28
28
|
- **Isolation must be on** (`specs/.core/env.config.json` exists **and** the spec
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
stop.
|
|
29
|
+
was provisioned by `/spec-start` — a worktree, or its branch in the checkout under
|
|
30
|
+
`mode: checkout`). If isolation is absent, there is nothing to land — the spec
|
|
31
|
+
is authored directly on `main` already. Say so and stop. `spec-env integrate`
|
|
32
|
+
reads `mode` and emits the matching plan; in checkout mode it rebases in place,
|
|
33
|
+
switches to base and fast-forwards, all in the one repo.
|
|
32
34
|
- **Feature / Bug specs only.** A **Hotfix** (`Type: Hotfix`) is built on a
|
|
33
35
|
release *tag* and cannot fast-forward onto `main` — refuse it and point the user
|
|
34
36
|
at `/spec-complete` (it lands a hotfix via tag + cherry-pick). Check the header
|
|
@@ -94,7 +96,7 @@ and continue — the branch has no commits base doesn't already have.
|
|
|
94
96
|
- Report: the base branch, the fast-forward result, and the green base test. It
|
|
95
97
|
**never pushes** — mention the user can `git push` the base branch themselves to
|
|
96
98
|
trigger CI / the shared env.
|
|
97
|
-
- Point the way forward: `/spec-
|
|
99
|
+
- Point the way forward: `/spec-next` to continue the remaining phases (you'll keep
|
|
98
100
|
committing on the same branch and can `/spec-to-main` again), and `/spec-complete`
|
|
99
101
|
when every phase is genuinely done — it will land the final commits, finalise,
|
|
100
102
|
and tear down.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.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
|
@@ -37,10 +37,10 @@ const {
|
|
|
37
37
|
planAbort,
|
|
38
38
|
} = require('./env/live.js')
|
|
39
39
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
40
|
-
const { planUp } = require('./env/provision.js')
|
|
41
|
-
const { planDown } = require('./env/teardown.js')
|
|
40
|
+
const { planUp, planCheckoutUp } = require('./env/provision.js')
|
|
41
|
+
const { planDown, planDownCheckout } = require('./env/teardown.js')
|
|
42
42
|
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
43
|
-
const { planIntegrate } = require('./env/integrate.js')
|
|
43
|
+
const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
|
|
44
44
|
const { planHotfixLand } = require('./env/hotfix.js')
|
|
45
45
|
const { planDev } = require('./env/dev.js')
|
|
46
46
|
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
@@ -233,9 +233,63 @@ function specEnvStatus(dir, config) {
|
|
|
233
233
|
// the /spec-env skill executes (git worktree add, docker compose up, .env,
|
|
234
234
|
// opener). This creates no worktree and starts no stack — the caller runs the
|
|
235
235
|
// printed commands. Keep the output's verb honest about that.
|
|
236
|
+
// `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
|
|
237
|
+
// planner, and prints the plan or the refusal.
|
|
238
|
+
function specEnvUpCheckout(dir, config, spec) {
|
|
239
|
+
const git = gitReader(dir)
|
|
240
|
+
const primary = assertPrimaryOnMain(config, git)
|
|
241
|
+
const base = resolveBaseBranch(config, git)
|
|
242
|
+
const status = git(['status', '--porcelain'])
|
|
243
|
+
|
|
244
|
+
const plan = planCheckoutUp(
|
|
245
|
+
spec,
|
|
246
|
+
{
|
|
247
|
+
current: primary.branch,
|
|
248
|
+
base,
|
|
249
|
+
onBase: primary.onBase,
|
|
250
|
+
// A null status means git could not be read at all. Treated as NOT clean:
|
|
251
|
+
// the harmless outcome of being wrong is a refusal the operator can act
|
|
252
|
+
// on, and the harmful one is carrying their work onto a new branch.
|
|
253
|
+
clean: status !== null && status.length === 0,
|
|
254
|
+
branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
|
|
255
|
+
checkoutPath: dir,
|
|
256
|
+
},
|
|
257
|
+
config,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if (plan.blocked) {
|
|
261
|
+
process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const out = [
|
|
266
|
+
`spec-env up: ${spec.folder} ` +
|
|
267
|
+
(plan.attached ? '(already on this branch — nothing to do)' : '(plan — nothing created yet)'),
|
|
268
|
+
'',
|
|
269
|
+
` mode: checkout (branch built in the primary checkout)`,
|
|
270
|
+
` checkout: ${plan.checkoutPath}`,
|
|
271
|
+
` branch: ${plan.branch}`,
|
|
272
|
+
' stack: checkout-only (no worktree, no docker, no port block)',
|
|
273
|
+
]
|
|
274
|
+
if (plan.commands.length) {
|
|
275
|
+
out.push('')
|
|
276
|
+
out.push(' to provision, run:')
|
|
277
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
278
|
+
}
|
|
279
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
280
|
+
}
|
|
281
|
+
|
|
236
282
|
function specEnvUp(dir, config, specArg) {
|
|
237
283
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
238
284
|
|
|
285
|
+
// Checkout mode: the branch is built in the primary checkout, so none of the
|
|
286
|
+
// worktree machinery below applies — no slot, no trust entry, no bootstrap and
|
|
287
|
+
// no opener. Handled first precisely so none of that runs by accident.
|
|
288
|
+
if (config.mode === 'checkout') {
|
|
289
|
+
specEnvUpCheckout(dir, config, spec)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
239
293
|
// Live-safe: if this spec is already live on the primary checkout (its branch was
|
|
240
294
|
// branch-switched in by `live take`), a `git worktree add` would fail — the branch
|
|
241
295
|
// is checked out there. Point the operator at the primary checkout rather than
|
|
@@ -251,7 +305,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
251
305
|
|
|
252
306
|
// Trust the shared worktree root so edits into the freshly-provisioned worktree
|
|
253
307
|
// don't prompt. One absolute entry (the root) covers every spec; self-heals on
|
|
254
|
-
// every provision for teammates who only cloned and ran /spec-
|
|
308
|
+
// every provision for teammates who only cloned and ran /spec-start.
|
|
255
309
|
const worktreeRootAbs = path.dirname(spec.worktreePath)
|
|
256
310
|
const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
|
|
257
311
|
|
|
@@ -295,6 +349,10 @@ function specEnvUp(dir, config, specArg) {
|
|
|
295
349
|
} else {
|
|
296
350
|
out.push(' stack: worktree-only (no docker, no port block)')
|
|
297
351
|
}
|
|
352
|
+
// The loader falls back silently on an unrecognised `mode`, so this line is
|
|
353
|
+
// the operator's only evidence of which mode actually resolved — print it
|
|
354
|
+
// whenever it was set explicitly, right or wrong.
|
|
355
|
+
out.push(' mode: worktree (each spec gets its own checkout)')
|
|
298
356
|
if (trust.reason === 'malformed') {
|
|
299
357
|
out.push(
|
|
300
358
|
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
@@ -429,12 +487,45 @@ function compactTimestamp() {
|
|
|
429
487
|
// when the spec was never provisioned / already torn down. Deliberately does NOT
|
|
430
488
|
// touch the trusted worktree root in .claude/settings.local.json — that entry is
|
|
431
489
|
// the shared parent of every spec's worktree and harmless when empty; removing it
|
|
432
|
-
// would just re-prompt on the next /spec-
|
|
490
|
+
// would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
|
|
433
491
|
function specEnvDown(dir, config, specArg, flags) {
|
|
434
492
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
435
493
|
|
|
436
494
|
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
437
495
|
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
496
|
+
// Checkout mode first: there is no worktree and no registry slot by design, so
|
|
497
|
+
// the "not provisioned" guard below would report a live spec as absent.
|
|
498
|
+
if (config.mode === 'checkout') {
|
|
499
|
+
const dgit = gitReader(dir)
|
|
500
|
+
const dbase = resolveBaseBranch(config, dgit)
|
|
501
|
+
if (dgit(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) === null) {
|
|
502
|
+
process.stdout.write(`spec-env down: ${spec.folder} has no branch — nothing to do.\n`)
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
const dst = dgit(['status', '--porcelain'])
|
|
506
|
+
const contains = dgit(['branch', '--contains', spec.branch, '--list', dbase])
|
|
507
|
+
const dplan = planDownCheckout(spec, config, flags, {
|
|
508
|
+
dirty: dst === null || dst.length > 0,
|
|
509
|
+
landed: Boolean(contains && contains.trim()),
|
|
510
|
+
onBranch: dgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
511
|
+
base: dbase,
|
|
512
|
+
checkoutPath: dir,
|
|
513
|
+
})
|
|
514
|
+
if (dplan.blocked) {
|
|
515
|
+
process.stdout.write(
|
|
516
|
+
`spec-env down: blocked — ${dplan.reason}.\n` +
|
|
517
|
+
'Re-run with --force to tear down anyway (deletes the branch).\n',
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const dout = [`spec-env down: ${spec.folder}`, '',
|
|
522
|
+
' mode: checkout (no worktree, no slot, no volumes)',
|
|
523
|
+
` branch: ${dplan.branch}`, '', ' run these:']
|
|
524
|
+
for (const cmd of dplan.commands) dout.push(` ${cmd}`)
|
|
525
|
+
process.stdout.write(dout.join('\n') + '\n')
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
|
|
438
529
|
const registry = readRegistry(dir, config)
|
|
439
530
|
const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
|
|
440
531
|
if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
|
|
@@ -566,7 +657,7 @@ function liveWorktreePaths(dir) {
|
|
|
566
657
|
* bucket disappears the moment it empties.
|
|
567
658
|
*
|
|
568
659
|
* Three outcomes, never two: resolved → that spec; several candidates and no cwd
|
|
569
|
-
* hint → throw, listing them; none → throw, pointing at /spec-
|
|
660
|
+
* hint → throw, listing them; none → throw, pointing at /spec-start. *Cannot tell*
|
|
570
661
|
* never becomes a guess.
|
|
571
662
|
*
|
|
572
663
|
* BLIND SPOT: a spec taken live with `/spec-live` has had its branch moved into
|
|
@@ -603,7 +694,7 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
|
|
|
603
694
|
if (provisioned.length === 0) {
|
|
604
695
|
throw new Error(
|
|
605
696
|
'no spec given, and no spec has a worktree — name one explicitly, or run ' +
|
|
606
|
-
'/spec-
|
|
697
|
+
'/spec-start to provision it.',
|
|
607
698
|
)
|
|
608
699
|
}
|
|
609
700
|
throw new Error(
|
|
@@ -764,6 +855,39 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
764
855
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
765
856
|
const base = resolveBaseBranch(config, gitReader(dir))
|
|
766
857
|
|
|
858
|
+
// Checkout mode short-circuits everything below. The live handling in
|
|
859
|
+
// particular reads "primary is on the spec's branch" as a live session — true
|
|
860
|
+
// in worktree mode, and simply where the branch LIVES in checkout mode, so it
|
|
861
|
+
// would refuse to land a spec sitting exactly where it belongs.
|
|
862
|
+
if (config.mode === 'checkout') {
|
|
863
|
+
const cgit = gitReader(dir)
|
|
864
|
+
const cst = cgit(['status', '--porcelain'])
|
|
865
|
+
const cahead = cgit(['rev-list', '--count', `${base}..${spec.branch}`])
|
|
866
|
+
const cplan = planIntegrateCheckout(spec, config, {
|
|
867
|
+
dirty: cst === null || cst.length > 0,
|
|
868
|
+
base,
|
|
869
|
+
aheadOfBase: cahead !== null && Number(cahead) > 0,
|
|
870
|
+
checkoutPath: dir,
|
|
871
|
+
onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
872
|
+
})
|
|
873
|
+
if (cplan.blocked) {
|
|
874
|
+
process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
if (cplan.noop) {
|
|
878
|
+
process.stdout.write(
|
|
879
|
+
`spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
|
|
880
|
+
)
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
const cout = [`spec-env integrate: ${spec.folder}`, '', ' mode: checkout',
|
|
884
|
+
` base: ${base}`, ` branch: ${cplan.branch}`, '',
|
|
885
|
+
' run these (abort the rebase on conflict):']
|
|
886
|
+
for (const cmd of cplan.commands) cout.push(` ${cmd}`)
|
|
887
|
+
process.stdout.write(cout.join('\n') + '\n')
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
767
891
|
// Live-aware: if this spec is live on the primary checkout (branch-switched by
|
|
768
892
|
// `live take`), end the live session first — release back to base, re-isolate the
|
|
769
893
|
// branch, clear the receipt — so the normal rebase→ff plan below applies
|
|
@@ -798,7 +922,7 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
798
922
|
const liveWtGit = gitReader(spec.worktreePath)
|
|
799
923
|
if (liveWtGit(['symbolic-ref', '--short', 'HEAD']) === null) {
|
|
800
924
|
// Detached worktree HEAD: any commits ahead of the branch ref (e.g. made by a
|
|
801
|
-
//
|
|
925
|
+
// a build that committed in the worktree) would be abandoned by the re-isolate `switch` below.
|
|
802
926
|
const stranded = liveWtGit(['rev-list', '--count', `${spec.branch}..HEAD`])
|
|
803
927
|
const head = liveWtGit(['rev-parse', '--short', 'HEAD'])
|
|
804
928
|
if (stranded !== null && Number(stranded) > 0) {
|
|
@@ -1050,6 +1174,16 @@ function proxyProcFor(config, routesFileAbs) {
|
|
|
1050
1174
|
// bundled proxy pointing at that spec's warm dev servers. `connect main` stops
|
|
1051
1175
|
// the proxy so the primary checkout owns the canonical ports again.
|
|
1052
1176
|
async function specEnvConnect(dir, config, specArg) {
|
|
1177
|
+
// Both verbs exist to route around the work living somewhere other than the
|
|
1178
|
+
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
1179
|
+
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
1180
|
+
if (config.mode === 'checkout') {
|
|
1181
|
+
process.stdout.write(
|
|
1182
|
+
'spec-env connect: not applicable in checkout mode — the spec is built in the ' +
|
|
1183
|
+
'primary checkout, so your dev server already serves it on the canonical ports.\\n',
|
|
1184
|
+
)
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1053
1187
|
const sdir = stateDirLabel(config)
|
|
1054
1188
|
const abs = (rel) => path.resolve(dir, rel)
|
|
1055
1189
|
const routesFile = `${sdir}/proxy.json`
|
|
@@ -1151,6 +1285,17 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
|
|
|
1151
1285
|
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
1152
1286
|
// (release/abort land in a later phase).
|
|
1153
1287
|
async function specEnvLive(dir, config, positional) {
|
|
1288
|
+
// Both verbs exist to route around the work living somewhere other than the
|
|
1289
|
+
// checkout you are in — a proxy to a second stack, or a temporary branch swap.
|
|
1290
|
+
// Checkout mode closes that gap permanently, so there is nothing to route.
|
|
1291
|
+
if (config.mode === 'checkout') {
|
|
1292
|
+
process.stdout.write(
|
|
1293
|
+
'spec-env live: not applicable in checkout mode — the spec branch is already ' +
|
|
1294
|
+
'checked out here. `mode: checkout` is the permanent form of what live overlay ' +
|
|
1295
|
+
'does temporarily.\\n',
|
|
1296
|
+
)
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1154
1299
|
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
1155
1300
|
switch (action) {
|
|
1156
1301
|
case 'status':
|
|
@@ -1210,6 +1355,10 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1210
1355
|
const status = primaryGit(['status', '--porcelain'])
|
|
1211
1356
|
const clean = status !== null && status.length === 0
|
|
1212
1357
|
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
1358
|
+
// The tree the rebase actually runs in. Unreadable → treated as dirty: being
|
|
1359
|
+
// wrong that way costs a message, the other way moves work we could not see.
|
|
1360
|
+
const wtStatus = worktreeExists ? gitReader(spec.worktreePath)(['status', '--porcelain']) : ''
|
|
1361
|
+
const worktreeClean = wtStatus !== null && wtStatus.length === 0
|
|
1213
1362
|
const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
|
|
1214
1363
|
|
|
1215
1364
|
// Diff base...branch to spot migration / dependency changes (best-effort).
|
|
@@ -1229,7 +1378,9 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1229
1378
|
const plan = planTake(spec, config, {
|
|
1230
1379
|
primary,
|
|
1231
1380
|
primaryPath: dir,
|
|
1381
|
+
inFlight: (readReceipt(dir, config) || {}).spec || null,
|
|
1232
1382
|
clean,
|
|
1383
|
+
worktreeClean,
|
|
1233
1384
|
worktreeExists,
|
|
1234
1385
|
base,
|
|
1235
1386
|
baseMainCommit,
|
|
@@ -1249,10 +1400,19 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
1249
1400
|
// Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
|
|
1250
1401
|
const reb = runGit(spec.worktreePath, ['rebase', base])
|
|
1251
1402
|
if (!reb.ok) {
|
|
1252
|
-
|
|
1403
|
+
// A rebase fails two ways and they need different answers. It can REFUSE TO
|
|
1404
|
+
// START (unstaged changes, a missing base) — nothing to abort, nothing to
|
|
1405
|
+
// resolve — or start and CONFLICT. Calling both "hit conflicts" sent people
|
|
1406
|
+
// hunting a conflict that did not exist, and `--abort` on a rebase that
|
|
1407
|
+
// never began discarded git's own explanation of what was actually wrong.
|
|
1408
|
+
const started = runGit(spec.worktreePath, ['rebase', '--show-current-patch']).ok
|
|
1409
|
+
if (started) runGit(spec.worktreePath, ['rebase', '--abort'])
|
|
1253
1410
|
process.stdout.write(
|
|
1254
|
-
|
|
1255
|
-
`
|
|
1411
|
+
started
|
|
1412
|
+
? `spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
|
|
1413
|
+
`resolve them in ${spec.worktreePath}, then retry.\n`
|
|
1414
|
+
: `spec-env live take: rebase of ${spec.branch} onto ${base} could not start — ` +
|
|
1415
|
+
`git said:\n ${reb.err.split('\n').join('\n ')}\n`,
|
|
1256
1416
|
)
|
|
1257
1417
|
return
|
|
1258
1418
|
}
|
|
@@ -1400,7 +1560,7 @@ async function specEnvLiveAbort(dir, config) {
|
|
|
1400
1560
|
function specEnvLiveStatus(dir, config, specArg) {
|
|
1401
1561
|
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
1402
1562
|
|
|
1403
|
-
// Per-spec query (`live status <spec>`): a clear yes/no verdict
|
|
1563
|
+
// Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
|
|
1404
1564
|
// skill branches on to decide whether to skip worktree provisioning and work in
|
|
1405
1565
|
// the primary checkout. The stable `live: yes|no` line is the machine seam.
|
|
1406
1566
|
if (specArg) {
|
|
@@ -1423,9 +1583,23 @@ function specEnvLiveStatus(dir, config, specArg) {
|
|
|
1423
1583
|
const state = onBase
|
|
1424
1584
|
? 'on base — free'
|
|
1425
1585
|
: `feature in control — not on ${baseBranch}`
|
|
1586
|
+
|
|
1587
|
+
// `in-flight:` is a MACHINE SEAM, like the per-spec `live:` line above, and
|
|
1588
|
+
// `/spec-next` reads it to decide which spec it is allowed to build. It answers
|
|
1589
|
+
// in three states rather than two, because "cannot tell" is real here: a branch
|
|
1590
|
+
// switched by hand carries no receipt, so the spec is unknown even though the
|
|
1591
|
+
// checkout is plainly busy. Reporting that as `none` would invite building the
|
|
1592
|
+
// wrong spec; reporting the branch says what is true and lets the caller stop.
|
|
1593
|
+
const inFlight = onBase
|
|
1594
|
+
? 'none — the workbench is free'
|
|
1595
|
+
: receipt && receipt.spec
|
|
1596
|
+
? `${receipt.spec} (branch ${branch || '(detached)'})`
|
|
1597
|
+
: `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
|
|
1598
|
+
|
|
1426
1599
|
process.stdout.write(
|
|
1427
1600
|
'spec-env live:\n' +
|
|
1428
1601
|
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
1602
|
+
` in-flight: ${inFlight}\n` +
|
|
1429
1603
|
` receipt: ${summarizeReceipt(receipt)}\n`,
|
|
1430
1604
|
)
|
|
1431
1605
|
}
|
|
@@ -1560,11 +1734,21 @@ async function run(argv) {
|
|
|
1560
1734
|
// Fresh repo (or create-missing): isolation defaults OFF; a flag or an
|
|
1561
1735
|
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1562
1736
|
let isolation = opts.isolation === true
|
|
1737
|
+
let workspaceMode = 'worktree'
|
|
1563
1738
|
if (interactive && !isExistingSetup(dir)) {
|
|
1564
1739
|
const { promptSetup } = require('./prompts.js')
|
|
1565
|
-
|
|
1740
|
+
const answers = await promptSetup({ isolationSeed: isolation })
|
|
1741
|
+
isolation = answers.isolation
|
|
1742
|
+
workspaceMode = answers.mode
|
|
1566
1743
|
}
|
|
1567
|
-
await init({
|
|
1744
|
+
await init({
|
|
1745
|
+
dir,
|
|
1746
|
+
force: opts.force,
|
|
1747
|
+
claudeMd: opts.claudeMd,
|
|
1748
|
+
mode: 'init',
|
|
1749
|
+
isolation,
|
|
1750
|
+
workspaceMode,
|
|
1751
|
+
})
|
|
1568
1752
|
break
|
|
1569
1753
|
}
|
|
1570
1754
|
case 'update':
|