@skitterbyte/skitterspec-linear 10.6.0 → 10.8.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/ci-stages.md +110 -0
- package/assets/core/env.config.md +18 -0
- package/assets/core/linear.config.json.example +4 -0
- package/assets/core/linear.config.md +88 -0
- package/assets/rules/commit-trailers.md +37 -5
- package/assets/rules/spec-planning.md +19 -4
- package/assets/skills/spec/SKILL.md +20 -0
- package/assets/skills/spec-bug/SKILL.md +48 -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 +49 -3
- package/assets/skills/spec-linear-setup/SKILL.md +38 -2
- package/assets/skills/spec-status/SKILL.md +1 -0
- package/assets/skills/spec-sync/SKILL.md +9 -0
- package/assets/skills/spec-to-main/SKILL.md +2 -1
- package/bin/skitterspec-linear.js +10 -0
- 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/src/vendor/linear/cli-sync.js +454 -34
- package/src/vendor/linear/config.js +158 -1
- package/src/vendor/linear/doctor.js +67 -1
- package/src/vendor/linear/released.js +149 -5
- package/src/vendor/sync-core/index.js +4 -1
- package/src/vendor/sync-core/src/compare.js +59 -3
- package/src/vendor/sync-core/src/normalize.js +65 -2
- package/assets/skills/spec-connect/SKILL.md +0 -59
- package/assets/skills/spec-live/SKILL.md +0 -73
|
@@ -155,8 +155,22 @@ that header — must exist **before** `spec-env up`:
|
|
|
155
155
|
bootstrap steps.
|
|
156
156
|
- Run the printed `git worktree add`. **The worktree is checked out at the tag,
|
|
157
157
|
so your uncommitted stub doesn't travel with it** — move it across so `main`
|
|
158
|
-
stays pristine
|
|
159
|
-
|
|
158
|
+
stays pristine. **Create the destination bucket first:**
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
mkdir -p <worktreePath>/specs/in-progress
|
|
162
|
+
mv specs/in-progress/hotfix-<name> <worktreePath>/specs/in-progress/
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The `mkdir -p` is not belt-and-braces. Git does not store empty directories, so
|
|
166
|
+
`specs/in-progress/` is **absent** from the worktree whenever nothing was in
|
|
167
|
+
progress at that point in history — and here that point is an **old release
|
|
168
|
+
tag**, where it is absent more often than not. `mv` into a missing destination
|
|
169
|
+
renames your spec folder **to** `specs/in-progress`, silently: the spec's files
|
|
170
|
+
end up one level too high, `00-overview.md` sits where the bucket should be, and
|
|
171
|
+
every later step still appears to work until something cannot find the spec.
|
|
172
|
+
Confirm the result before carrying on — you want
|
|
173
|
+
`<worktreePath>/specs/in-progress/hotfix-<name>/00-overview.md`.
|
|
160
174
|
- **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
|
|
161
175
|
none of the repo's gitignored files (`.env`, local overrides). Run the printed
|
|
162
176
|
`in the worktree, run:` steps (file seeding, then setup) in order, before
|
|
@@ -309,6 +323,38 @@ and **never auto-push git** — Linear's own automation reacts to real branch/PR
|
|
|
309
323
|
events later. Report the Linear issue URL as part of the skill's finish-up
|
|
310
324
|
message.
|
|
311
325
|
|
|
326
|
+
### Picking the Linear Project
|
|
327
|
+
|
|
328
|
+
Run this **only when minting a spec issue** — creating it for the first time. On
|
|
329
|
+
an update the issue already has a project (or deliberately has none), and that
|
|
330
|
+
placement is **Linear's to own**: never send `project` on an update, and never
|
|
331
|
+
record the choice in the spec file or the snapshot. A PM re-homing a spec issue
|
|
332
|
+
must not show up as drift or be overwritten on the next push.
|
|
333
|
+
|
|
334
|
+
1. **List the candidates.** Ask the engine:
|
|
335
|
+
`skitterspec spec-sync projects --json`. On the API path it returns the team's
|
|
336
|
+
projects; on the MCP path it says so, and you call the discovered project-list
|
|
337
|
+
tool instead. Drop archived / completed projects — they can't take new work.
|
|
338
|
+
2. **Offer them.** Show the names (most recently updated first is fine), plus an
|
|
339
|
+
explicit **None (team only)** option. Pre-select `linear.projectId` from
|
|
340
|
+
`linear.config.json` when it's set and still in the list; otherwise pre-select
|
|
341
|
+
**None**.
|
|
342
|
+
3. **Narrow on request.** If the user types a fragment rather than choosing, filter
|
|
343
|
+
the list case-insensitively by name and re-offer. Don't re-fetch.
|
|
344
|
+
4. **Never offer to create a project.** Projects are the PM's surface — if none
|
|
345
|
+
fits, that's **None (team only)**, and someone makes the project in Linear.
|
|
346
|
+
5. **Pass it once**, to whichever thing mints the issue: `--project <chosen id>`
|
|
347
|
+
on `spec-sync apply`, or `project: <chosen id>` on the MCP issue-create call.
|
|
348
|
+
Chose None → omit it entirely (do not pass an empty string).
|
|
349
|
+
|
|
350
|
+
**Degrade, never block.** If the list can't be fetched — Linear not connected, no
|
|
351
|
+
project-list tool, no API key, or `spec-sync projects` reporting it couldn't ask —
|
|
352
|
+
say so in one line — *"project picker unavailable"* — and carry on with
|
|
353
|
+
`linear.projectId` if it's set, else no project at all. A missing picker must never fail the skill that called it.
|
|
354
|
+
|
|
355
|
+
If `linear.projectId` is set but that Project is archived or missing, relay
|
|
356
|
+
Linear's error and stop rather than silently minting an unparented issue.
|
|
357
|
+
|
|
312
358
|
## 6. Drive to GREEN
|
|
313
359
|
|
|
314
360
|
- Implement the **minimal, root-cause** fix on the branch. Match surrounding code;
|
|
@@ -349,7 +395,7 @@ full test result. The spec stays in `in-progress`.
|
|
|
349
395
|
|
|
350
396
|
- **`/spec-live` is refused for a hotfix** — its branch is built on an old tag, so
|
|
351
397
|
hot-reloading it onto the running dev server could break the shared instance.
|
|
352
|
-
To test it,
|
|
398
|
+
To test it, the user runs `/spec-connect` (its own isolated stack).
|
|
353
399
|
- Suggest **`/spec-complete`** to land it: it patch-bumps the base tag, tags the
|
|
354
400
|
hotfix branch **locally** (you push it to deploy), and cherry-picks the fix onto
|
|
355
401
|
`main`. Add `--also <tag>` at completion to also patch other release lines
|
|
@@ -142,7 +142,38 @@ never apply one they didn't agree to), and re-run with the `--state` flags. If i
|
|
|
142
142
|
made no suggestion for a bucket, **ask** which state means "finished" here rather
|
|
143
143
|
than guessing.
|
|
144
144
|
|
|
145
|
-
## 7.
|
|
145
|
+
## 7. Does this repo deploy through stages? (optional)
|
|
146
|
+
|
|
147
|
+
A spec's lifecycle ends at `complete`. Where a ticket goes **after** that —
|
|
148
|
+
deployed to test, approved for demo, live in production — is a fact about an
|
|
149
|
+
environment, and nothing in the repo can derive it. If the team runs a deploy
|
|
150
|
+
pipeline, it can declare that ladder here and CI moves tickets along it with
|
|
151
|
+
`spec-sync stage`.
|
|
152
|
+
|
|
153
|
+
**Ask, do not assume.** Most projects have no ladder, and an invented one is
|
|
154
|
+
worse than none: `spec-sync stage` refuses cleanly when none is declared, whereas
|
|
155
|
+
a wrong ladder is a pipeline quietly moving tickets to the wrong column. If the
|
|
156
|
+
user does not raise deployment, offer it once in a line and take "no" for an
|
|
157
|
+
answer.
|
|
158
|
+
|
|
159
|
+
If they do want one, ask for the stages **in deployment order**, and map each to
|
|
160
|
+
a state from the `list_issue_statuses` names you already have:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
--stage test="On Test" --stage demo="Ready for Demo" --stage prod="Done"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- The **key** (`test`) is what a pipeline names; the **state** is the Linear
|
|
167
|
+
column. Pipelines reference the key, so renaming the column later is one edit
|
|
168
|
+
here.
|
|
169
|
+
- Order is recorded, not enforced — a rollback and a hotfix straight to prod are
|
|
170
|
+
both legitimate and are never refused.
|
|
171
|
+
- **Ask what closes an issue.** If the last rung is not a completed-type state,
|
|
172
|
+
tickets that finish the ladder never reach Done. That is fine when Linear
|
|
173
|
+
automation closes them and a problem otherwise — `spec-sync doctor` warns about
|
|
174
|
+
it either way, so say which it is.
|
|
175
|
+
|
|
176
|
+
## 8. Write it
|
|
146
177
|
|
|
147
178
|
```
|
|
148
179
|
skitterspec spec-sync init-config \
|
|
@@ -157,12 +188,17 @@ shows this repo's choices and keeps inheriting everything else. `--force` is
|
|
|
157
188
|
required to replace an existing config — never pass it without the user having
|
|
158
189
|
asked for a rewrite in step 1.
|
|
159
190
|
|
|
191
|
+
Every `--stage` state is checked against the workspace exactly like a bucket
|
|
192
|
+
state, and a bad one is refused the same way — as `release.stages[test]` rather
|
|
193
|
+
than `states.complete`. There is no suggestion for a rung: the vocabulary is the
|
|
194
|
+
project's own, so ask rather than guess.
|
|
195
|
+
|
|
160
196
|
**Relay the engine's report as printed.** It names the team, the project (or
|
|
161
197
|
"team only"), the intake labels, and how many state names were checked against
|
|
162
198
|
the workspace — that report is the evidence the setup is right, so don't
|
|
163
199
|
paraphrase it into "done".
|
|
164
200
|
|
|
165
|
-
##
|
|
201
|
+
## 9. Report and hand off
|
|
166
202
|
|
|
167
203
|
**Finish by checking, not by describing.** First write down what the MCP server
|
|
168
204
|
says, from the reads you already made in step 2 — no extra round trip unless a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: spec-status
|
|
3
3
|
description: Show a spec's one-way sync status against Linear — a read-only drift report. Reports whether the spec changed since the last push (there's something to push) and, optionally, whether Linear's issue workflow-state differs from the spec's. Fetches the Linear issue over MCP and runs `skitterspec spec-sync status`. Changes nothing. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-status", "is this spec in sync with Linear", "what would push", or "show spec sync status".
|
|
4
|
+
disable-model-invocation: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# /spec-status — one-way sync drift report
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: spec-sync
|
|
3
3
|
description: Run the repo-wide spec-sync operations against Linear — what's linked, the workspace's states and projects, read-back verification, manual stamping, and bulk `apply --all <bucket>`. Run it bare for the repo-wide overview. Wraps the `spec-sync` CLI, which is a local devDependency and never on PATH, so this skill always states the full invocation. Defers to /spec-push and /spec-status for per-spec work. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-sync", "run spec-sync", "what's linked to Linear", "which states does the workspace have", or "mirror the whole backlog".
|
|
4
|
+
disable-model-invocation: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# /spec-sync — the repo-wide Linear sync operations
|
|
@@ -155,6 +156,14 @@ Relay the **unreferenced count** even when it is zero. A chore commit
|
|
|
155
156
|
legitimately carries no ticket and a *missed* trailer looks identical, so
|
|
156
157
|
omitting the number reads as "everything is accounted for" when it may not be.
|
|
157
158
|
|
|
159
|
+
**Bookkeeping commits are excluded.** A spec's `chore(spec): complete <name>`
|
|
160
|
+
commit carries the same ref as the code it describes but lands *after* the tag
|
|
161
|
+
that shipped it, so counting it would put the ticket in two consecutive releases.
|
|
162
|
+
A commit whose changed paths are **all** under `release.ignorePaths` (default
|
|
163
|
+
`specs/`) contributes nothing; one that touches an ignored path *and* a source
|
|
164
|
+
file still counts. Relay the **ignored count** whenever it is non-zero — that is
|
|
165
|
+
the report saying which commits it set aside.
|
|
166
|
+
|
|
158
167
|
## 8. `retarget` — after a Linear team is renamed
|
|
159
168
|
|
|
160
169
|
```
|
|
@@ -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
|
|
|
@@ -41,6 +41,16 @@ const {
|
|
|
41
41
|
async function main(argv) {
|
|
42
42
|
const [cmd, ...rest] = argv
|
|
43
43
|
|
|
44
|
+
// The check above asks whether src/ exists, which is inert in a workspace source
|
|
45
|
+
// package — src/ is committed there. That is exactly where the other half of the
|
|
46
|
+
// problem lives: a source package HAS a runnable bin and src, but its assets/ is
|
|
47
|
+
// PRE-composition (seam markers still literal). Installing from it writes those
|
|
48
|
+
// markers into the user's skills. So ask a second, positive question before any
|
|
49
|
+
// install command runs.
|
|
50
|
+
if (cmd === 'init' || cmd === 'update') {
|
|
51
|
+
require('../src/init.js').assertComposedAssets()
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
// Base help + what this distribution adds. Matched on the COMMAND SLOT only,
|
|
45
55
|
// never the whole argv: `spec-sanitise --help` must reach that command's own
|
|
46
56
|
// help, not be swallowed by the top-level one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.8.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
|
|
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
|
}
|