@skitterbyte/skitterspec 22.3.1 → 22.4.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/core/env.config.md +13 -1
- package/assets/review/page.html +46 -4
- package/assets/skills/spec-bug/SKILL.md +51 -20
- package/assets/skills/spec-hotfix/SKILL.md +68 -36
- package/package.json +1 -1
- package/src/cli.js +97 -18
- package/src/env/provision.js +71 -10
- package/src/env/teardown.js +4 -0
|
@@ -90,7 +90,17 @@ through to a documented conservative default; see the field notes below.
|
|
|
90
90
|
"projectNamePattern": "{repoSlug}_{slug}", // → COMPOSE_PROJECT_NAME
|
|
91
91
|
"portBase": 3000, // first port of slot 0's block
|
|
92
92
|
"portsPerSpec": 10, // block width; slot n → portBase + n*portsPerSpec
|
|
93
|
-
|
|
93
|
+
// Written into the worktree, and passed to compose as `--env-file`. A
|
|
94
|
+
// Docker spec's worktree steps run in this order: seedFiles, this env file
|
|
95
|
+
// (COMPOSE_PROJECT_NAME and PORT_OFFSET appended only where missing, so a
|
|
96
|
+
// value `setup` or you put there is kept), `setup`, then
|
|
97
|
+
// `docker compose … up -d` — all from inside the worktree, so the stack
|
|
98
|
+
// reads this spec's values and not the primary checkout's.
|
|
99
|
+
// It must be a file of its OWN: if it is also in `seedFiles` as a symlink,
|
|
100
|
+
// the write is refused rather than send this spec's port block into the
|
|
101
|
+
// primary checkout's file. Name a different file here (".worktree.env")
|
|
102
|
+
// and have your compose file read the values from it.
|
|
103
|
+
"envFile": ".env",
|
|
94
104
|
"backupCommand": "" // optional pre-teardown backup (e.g. pg_dump);
|
|
95
105
|
// empty = no backup, volumes dropped directly.
|
|
96
106
|
},
|
|
@@ -116,6 +126,8 @@ through to a documented conservative default; see the field notes below.
|
|
|
116
126
|
// `local.settings.json`, `config/local.yml`.
|
|
117
127
|
// Seeded files are gitignored, so they never make the worktree "dirty" and
|
|
118
128
|
// never block teardown; they vanish with the worktree at `spec-env down`.
|
|
129
|
+
// On a Docker project, do not seed `docker.envFile` as a symlink — the env
|
|
130
|
+
// file step refuses to write through one (see `envFile` above).
|
|
119
131
|
// [] (or absent) = seed nothing (current behaviour).
|
|
120
132
|
"seedFiles": {
|
|
121
133
|
"mode": "symlink",
|
package/assets/review/page.html
CHANGED
|
@@ -1044,10 +1044,43 @@ a.ticket:hover { border-color: var(--accent); }
|
|
|
1044
1044
|
return bands
|
|
1045
1045
|
}
|
|
1046
1046
|
|
|
1047
|
+
// --- one order, for both columns -----------------------------------------
|
|
1048
|
+
// The sidebar and the panes are two views of one list, and they must be the
|
|
1049
|
+
// SAME list in the same order — a reader scrolling the panes watches the
|
|
1050
|
+
// current-file mark walk the sidebar, and a mark that walks backwards is the
|
|
1051
|
+
// whole reason this exists.
|
|
1052
|
+
//
|
|
1053
|
+
// The emitted order cannot be it. `review.js` collects tracked changes and
|
|
1054
|
+
// untracked files in two passes, so a new file is appended after everything
|
|
1055
|
+
// else however its path sorts; git's own order is not the tree's either.
|
|
1056
|
+
//
|
|
1057
|
+
// So the tree's order is the one order, settled here and used by both. It is
|
|
1058
|
+
// expressed as a comparator rather than derived from the built tree because
|
|
1059
|
+
// the panes are built long before the tree is, and a second traversal that
|
|
1060
|
+
// had to agree with the first is the thing that went wrong.
|
|
1061
|
+
function treeOrder(a, b) {
|
|
1062
|
+
var A = a.split('/')
|
|
1063
|
+
var B = b.split('/')
|
|
1064
|
+
var n = Math.min(A.length, B.length)
|
|
1065
|
+
for (var i = 0; i < n; i++) {
|
|
1066
|
+
// A segment with anything after it is a directory. `renderTree` walks
|
|
1067
|
+
// every directory at a level — label and subtree — before that level's
|
|
1068
|
+
// files, so a directory sorts ahead of a file it ties with by name.
|
|
1069
|
+
var aDir = i < A.length - 1
|
|
1070
|
+
var bDir = i < B.length - 1
|
|
1071
|
+
if (aDir !== bDir) return aDir ? -1 : 1
|
|
1072
|
+
if (A[i] !== B[i]) return A[i].localeCompare(B[i])
|
|
1073
|
+
}
|
|
1074
|
+
return 0
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// A copy: `data` is the emitted review, and the page is a view of it.
|
|
1078
|
+
var files = data.files.slice().sort(function (a, b) { return treeOrder(a.path, b.path) })
|
|
1079
|
+
|
|
1047
1080
|
// --- per-file page state -------------------------------------------------
|
|
1048
1081
|
// Band state lives HERE, never in the emitted data: the page is a view of an
|
|
1049
1082
|
// immutable review, and expanding context is a property of this reading of it.
|
|
1050
|
-
var state =
|
|
1083
|
+
var state = files.map(function (f) {
|
|
1051
1084
|
var rows = parsePatch(f.patch)
|
|
1052
1085
|
var st = {
|
|
1053
1086
|
file: f,
|
|
@@ -1717,7 +1750,16 @@ a.ticket:hover { border-color: var(--accent); }
|
|
|
1717
1750
|
function renderTree(node) {
|
|
1718
1751
|
var ul = el('ul')
|
|
1719
1752
|
;(function walk(n, depth) {
|
|
1720
|
-
|
|
1753
|
+
// `treeOrder` above is the authority and these two sorts must match it,
|
|
1754
|
+
// which is why the directories sort by `localeCompare` rather than by the
|
|
1755
|
+
// default `.sort()`: that compares UTF-16 code units, so it puts every
|
|
1756
|
+
// capitalised name ahead of every lowercase one and the panes would drift
|
|
1757
|
+
// from the sidebar again on the first `README/` in a diff.
|
|
1758
|
+
//
|
|
1759
|
+
// Insertion order would be free — the list arrives sorted — but a key
|
|
1760
|
+
// that looks like an array index (`2024/`) is enumerated first whatever
|
|
1761
|
+
// order it was inserted in, and a dated directory is not exotic.
|
|
1762
|
+
Object.keys(n.dirs).sort(function (a, b) { return a.localeCompare(b) }).forEach(function (name) {
|
|
1721
1763
|
var li = el('li')
|
|
1722
1764
|
li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
|
|
1723
1765
|
ul.appendChild(li)
|
|
@@ -1750,9 +1792,9 @@ a.ticket:hover { border-color: var(--accent); }
|
|
|
1750
1792
|
}
|
|
1751
1793
|
|
|
1752
1794
|
var tree = document.getElementById('tree')
|
|
1753
|
-
tree.appendChild(renderTree(buildTree(
|
|
1795
|
+
tree.appendChild(renderTree(buildTree(files)))
|
|
1754
1796
|
document.getElementById('tree-summary').textContent =
|
|
1755
|
-
'Files (' +
|
|
1797
|
+
'Files (' + files.length + ')'
|
|
1756
1798
|
if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
|
|
1757
1799
|
document.getElementById('tree-wrap').open = false
|
|
1758
1800
|
}
|
|
@@ -47,21 +47,49 @@ bug's **own branch**, never directly on `main`. Provision the worktree **now**,
|
|
|
47
47
|
before the failing test, so the test, the fix, and the spec all land together and
|
|
48
48
|
arrive as one reviewable PR.
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
**Provision before you write, not after.** §1 put nothing on disk and the bug's
|
|
51
|
+
name is settled by the end of it, so this is the one moment where a worktree
|
|
52
|
+
costs nothing to obtain and everything — the red test, the fix, the spec — is
|
|
53
|
+
still ahead of it:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
skitterspec spec-env up bug-<name> --docs
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**The spec does not exist yet, and that is the lane.** `up` opens its authoring
|
|
60
|
+
branch on exactly three signals — a spec it could not find, `--docs`, and a name
|
|
61
|
+
shaped `bug-<something>` — records the branch so the name resolves from here on,
|
|
62
|
+
and prints a `git worktree add … -b bug/<name>` forked from `main`. Run what it
|
|
63
|
+
prints, then move this session into the worktree with a plain `cd`; the Bash
|
|
64
|
+
working directory persists between calls, so from here on the test, the fix and
|
|
65
|
+
the spec are all written on the bug's own branch. Confirm the move landed rather
|
|
66
|
+
than reading silence as success (`.claude/rules/negative-checks.md` rule 1):
|
|
67
|
+
`skitterspec spec-env resolve` with no argument must name this spec. If it does
|
|
68
|
+
not, say so and stop rather than writing into a tree you cannot name.
|
|
69
|
+
|
|
70
|
+
**Then run `up` again, without the flag:**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
skitterspec spec-env up bug-<name>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`--docs` skips the `setup` commands — everything whose only purpose is making a
|
|
77
|
+
tree *runnable* — because provisioning somewhere to write markdown should cost a
|
|
78
|
+
`git worktree add` and nothing else. `/spec` leaves them to `/spec-start`, but
|
|
79
|
+
**there is no `/spec-start` in a bug's life**: §3 runs the project's suite three
|
|
80
|
+
steps from here, in this same tree, and
|
|
81
|
+
**a tree that cannot run the suite cannot go red**.
|
|
82
|
+
The flagless re-run re-attaches the worktree that now exists
|
|
83
|
+
(no second `-b` fork) and prints the bootstrap steps the flag deferred.
|
|
84
|
+
|
|
85
|
+
**Why the stub is no longer seeded on the base branch.** This step used to open
|
|
86
|
+
by writing `specs/in-progress/bug-<name>/00-overview.md` in the primary checkout
|
|
87
|
+
for the engine to resolve, and `main` is where work **lands**, not where it
|
|
88
|
+
happens: where the project installs `.claude/hooks/main-guard.cjs` that write is
|
|
89
|
+
refused, and the skill's documented first instruction becomes the one thing the
|
|
90
|
+
repo will not do. Nothing is seeded and nothing is moved now — the spec is
|
|
91
|
+
written in the worktree, at §4, on the branch it belongs to.
|
|
92
|
+
|
|
65
93
|
- **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
|
|
66
94
|
none of the repo's gitignored files (`.env`, local overrides). Run the printed
|
|
67
95
|
`in the worktree, run:` steps (file seeding, then `setup`) in order, before
|
|
@@ -71,9 +99,10 @@ provision from — you'll flesh it out in §4:
|
|
|
71
99
|
— run `/add-dir <trusted root>` before editing into the worktree, or the first
|
|
72
100
|
edits will prompt.
|
|
73
101
|
- **Do everything below in the worktree**, on the branch — the red test, the fix,
|
|
74
|
-
and the rest of the spec.
|
|
75
|
-
|
|
76
|
-
changes only when the branch
|
|
102
|
+
and the rest of the spec. The `cd` above is what makes that the default rather
|
|
103
|
+
than something to remember; where a step has to reach out of the tree, use an
|
|
104
|
+
absolute path or `git -C <worktreePath>`. `main` changes only when the branch
|
|
105
|
+
merges (at `/spec-complete`).
|
|
77
106
|
|
|
78
107
|
## 3. Write the failing test FIRST (RED) — mandatory
|
|
79
108
|
|
|
@@ -89,8 +118,10 @@ Encode the **correct** (expected) behaviour as a test, then run it and confirm i
|
|
|
89
118
|
|
|
90
119
|
## 4. Write the Bug spec
|
|
91
120
|
|
|
92
|
-
|
|
93
|
-
|
|
121
|
+
Write the spec's entry point `00-overview.md`. **When isolated**, you are already
|
|
122
|
+
standing in this spec's own worktree — §2 provisioned it and moved you there, so
|
|
123
|
+
create the folder and write it here, on the bug's branch. Nothing was seeded
|
|
124
|
+
ahead of it and nothing is moved across.
|
|
94
125
|
**When not isolated**, create the spec **folder**
|
|
95
126
|
`specs/in-progress/bug-<kebab-name>/` with its entry point `00-overview.md` now
|
|
96
127
|
(every spec is a folder — never a bare file). A bug is
|
|
@@ -55,39 +55,69 @@ Hotfixes are concrete — confirm, don't over-grill. Establish:
|
|
|
55
55
|
- **Root cause:** read the code **at the base tag** and trace it to `file:line`.
|
|
56
56
|
The fix belongs on the tag's line, so reason about that code, not `main`'s.
|
|
57
57
|
|
|
58
|
-
## 3.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
58
|
+
## 3. Provision the worktree from the tag, then write in it
|
|
59
|
+
|
|
60
|
+
**Provision before you write, not after.** §1 settled the tag and the name and
|
|
61
|
+
§2 put nothing on disk, so this is the one moment where a worktree costs nothing
|
|
62
|
+
to obtain and everything — the red test, the fix, the spec — is still ahead of
|
|
63
|
+
it:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
skitterspec spec-env up hotfix-<name> --docs --from <tag>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**`--from` is how the tag reaches the engine, and it is not the header twice.**
|
|
70
|
+
Everywhere else `up` forks a hotfix from the spec's own
|
|
71
|
+
`> **Base version:**` line — but here the spec that would carry it does not
|
|
72
|
+
exist yet, which is the whole of what this lane is for. Without the flag the
|
|
73
|
+
branch would fork from `main`: the fix gets written against code prod is not
|
|
74
|
+
running, while the tag and the cherry-pick downstream go on believing it is on
|
|
75
|
+
the release line, and nothing says otherwise until someone asks why the patch
|
|
76
|
+
did not take. The engine refuses a ref git does not know rather than forking
|
|
77
|
+
from somewhere else quietly; where git cannot answer at all it passes the ref
|
|
78
|
+
through to `git worktree add`, which refuses far more loudly than a planner
|
|
79
|
+
guessing from an absence.
|
|
80
|
+
|
|
81
|
+
Run what it prints — the `git worktree add … -b hotfix/<slug> <tag>` is checked
|
|
82
|
+
out **at the tag**, not at `main` — then move this session into the worktree
|
|
83
|
+
with a plain `cd`; the Bash working directory persists between calls, so from
|
|
84
|
+
here on the test, the fix and the spec are all written on the hotfix's own
|
|
85
|
+
branch. Confirm the move landed rather than reading silence as success
|
|
86
|
+
(`.claude/rules/negative-checks.md` rule 1): `skitterspec spec-env resolve` with
|
|
87
|
+
no argument must name this spec. If it does not, say so and stop rather than
|
|
88
|
+
writing into a tree you cannot name.
|
|
89
|
+
|
|
90
|
+
**Then run `up` again, without the flags:**
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
skitterspec spec-env up hotfix-<name>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--docs` skips the `setup` commands — everything whose only purpose is making a
|
|
97
|
+
tree *runnable* — because provisioning somewhere to write markdown should cost a
|
|
98
|
+
`git worktree add` and nothing else. A hotfix has no `/spec-start` to leave them
|
|
99
|
+
to: §4 runs the project's suite in this same tree, and
|
|
100
|
+
**a tree that cannot run the suite cannot go red**.
|
|
101
|
+
The flagless re-run re-attaches the worktree that now exists (no second `-b`
|
|
102
|
+
fork, and no fork point — the branch is already on the tag's line) and prints
|
|
103
|
+
the bootstrap steps the flag deferred.
|
|
104
|
+
|
|
105
|
+
**Why nothing is seeded on the base branch, and nothing is moved.** This step
|
|
106
|
+
used to write `specs/in-progress/hotfix-<name>/00-overview.md` in the primary
|
|
107
|
+
checkout for the engine to resolve, and then `mv` the folder across into a
|
|
108
|
+
worktree checked out at a tag — a move with a hazard paragraph of its own, since
|
|
109
|
+
`specs/in-progress/` is usually absent at an old release tag. Both halves are
|
|
110
|
+
gone. `main` is where work **lands**, not where it happens: where the project
|
|
111
|
+
installs `.claude/hooks/main-guard.cjs` that first write is refused outright,
|
|
112
|
+
and a step whose documented opening the repo will not perform is not a step. The
|
|
113
|
+
spec is written in the worktree at §5, on the branch it belongs to.
|
|
114
|
+
|
|
115
|
+
**§5 records the same tag you passed to `--from`**, on the spec's
|
|
116
|
+
`> **Base version:**` header. That is not bookkeeping: `/spec-complete` tags the
|
|
117
|
+
new patch and cherry-picks off the header, so a worktree forked from one ref and
|
|
118
|
+
a header naming another is this whole bug again with the halves swapped.
|
|
119
|
+
|
|
120
|
+
- **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
|
|
91
121
|
none of the repo's gitignored files (`.env`, local overrides). Run the printed
|
|
92
122
|
`in the worktree, run:` steps (file seeding, then `setup`) in order, before
|
|
93
123
|
anything else.
|
|
@@ -96,8 +126,9 @@ that header — must exist **before** `spec-env up`:
|
|
|
96
126
|
— run `/add-dir <trusted root>` before editing into the worktree, or the first
|
|
97
127
|
edits will prompt.
|
|
98
128
|
- **Do everything below in the worktree**, on the `hotfix/<slug>` branch — the red
|
|
99
|
-
test, the fix, and the rest of the spec.
|
|
100
|
-
|
|
129
|
+
test, the fix, and the rest of the spec. The `cd` above is what makes that the
|
|
130
|
+
default rather than something to remember; where a step has to reach out of
|
|
131
|
+
the tree, use an absolute path or `git -C <worktreePath>`. `main` changes
|
|
101
132
|
only at `/spec-complete` (via cherry-pick, not merge).
|
|
102
133
|
|
|
103
134
|
## 4. Write the failing test FIRST (RED) — mandatory
|
|
@@ -114,7 +145,8 @@ Encode the **correct** (expected) behaviour as a test, then run it and confirm i
|
|
|
114
145
|
|
|
115
146
|
## 5. Write the Hotfix spec
|
|
116
147
|
|
|
117
|
-
|
|
148
|
+
Write `00-overview.md` in the worktree — §3 provisioned it at the tag and moved
|
|
149
|
+
you there, so create the folder and write it here. A hotfix
|
|
118
150
|
is usually a single-pass fix, so the `## Fix` block can live directly in
|
|
119
151
|
`00-overview.md`. Keep it lean:
|
|
120
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.4.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code \u2014 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
|
@@ -863,6 +863,9 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
863
863
|
// THE AUTHORING LANE — `up <name> --docs` for a spec that is not written yet.
|
|
864
864
|
// `/spec` provisions BEFORE it writes a line (feat-main-is-a-landing-zone
|
|
865
865
|
// phase 2), so the name resolves to nothing at exactly the moment this runs.
|
|
866
|
+
// `/spec-bug` takes the same lane and then re-runs `up` WITHOUT the flag for
|
|
867
|
+
// the `setup` commands it skipped — it has no `/spec-start` to defer them to,
|
|
868
|
+
// and its next step runs the suite in that tree.
|
|
866
869
|
// It opens only on a positive signal, all three parts: the not-found refusal
|
|
867
870
|
// itself (by code, never by message), `--docs`, and a spec-shaped name — so a
|
|
868
871
|
// typo'd `up` without the flag, or with a name no spec could have, refuses
|
|
@@ -883,6 +886,19 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
883
886
|
// a caller that runs the printed commands must never own a worktree the
|
|
884
887
|
// engine cannot name. `spec: true` is what makes the record resolve with
|
|
885
888
|
// the spec's own type and slug rather than as `/no-spec` work.
|
|
889
|
+
// `--from <ref>` is the fork point for a spec whose own header cannot yet
|
|
890
|
+
// supply one. `/spec-hotfix` is the caller: its worktree belongs on the
|
|
891
|
+
// released line, and `> **Base version:**` is written INSIDE that tree.
|
|
892
|
+
// Everywhere else `spec.baseRef` comes off the spec and stays the
|
|
893
|
+
// authority — which is why passing this to a spec that resolves refuses
|
|
894
|
+
// below rather than quietly losing to the header.
|
|
895
|
+
if (flags.from !== undefined) {
|
|
896
|
+
const bad = badForkRef(dir, flags.from)
|
|
897
|
+
if (bad) {
|
|
898
|
+
process.stdout.write(bad)
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
}
|
|
886
902
|
const beforeAuth = readRegistry(dir, config)
|
|
887
903
|
const resolved = resolveSpecless(specArg, dir, config, { spec: true })
|
|
888
904
|
writeRegistry(
|
|
@@ -891,9 +907,23 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
891
907
|
recordSpecless(beforeAuth, specArg, { branch: resolved.branch, spec: true }),
|
|
892
908
|
)
|
|
893
909
|
spec = resolved
|
|
910
|
+
if (flags.from !== undefined) spec.baseRef = flags.from
|
|
894
911
|
authoring = true
|
|
895
912
|
}
|
|
896
913
|
|
|
914
|
+
// A flag that cannot be honoured is refused, never ignored. The operator
|
|
915
|
+
// typed a fork point; silently forking from base instead would build a hotfix
|
|
916
|
+
// against `main`'s code while everything downstream believes it is on the
|
|
917
|
+
// release line — the exact silent failure this flag exists to prevent.
|
|
918
|
+
if (flags.from !== undefined && !authoring) {
|
|
919
|
+
process.stdout.write(
|
|
920
|
+
`spec-env up: --from applies to a spec that is not written yet. ` +
|
|
921
|
+
`${specArg} already exists, and its own \`> **Base version:**\` header is ` +
|
|
922
|
+
'the fork point — edit that instead.\n',
|
|
923
|
+
)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
|
|
897
927
|
// Checkout mode: the branch is built in the primary checkout, so none of the
|
|
898
928
|
// worktree machinery below applies — no slot, no trust entry and no bootstrap.
|
|
899
929
|
// Handled first precisely so none of that runs by accident.
|
|
@@ -933,13 +963,24 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
933
963
|
const upGit = gitReader(dir)
|
|
934
964
|
const upStatus = upGit(['status', '--porcelain'])
|
|
935
965
|
const upDirtyPaths = dirtyPaths(upGit)
|
|
936
|
-
//
|
|
937
|
-
// known to be nowhere, and that is the healthy state of
|
|
938
|
-
// "would fork without the spec it is for"
|
|
939
|
-
// gate's cannot-tell branch, which allows
|
|
940
|
-
// untracked read is skipped the same way: there
|
|
941
|
-
|
|
942
|
-
|
|
966
|
+
// A spec with no document on disk does not ask whether the spec is on the
|
|
967
|
+
// fork point — it is known to be nowhere, and that is the healthy state of
|
|
968
|
+
// the authoring lane, not the "would fork without the spec it is for"
|
|
969
|
+
// refusal. `onFork: undefined` is the gate's cannot-tell branch, which allows
|
|
970
|
+
// (negative-checks rule 4). The untracked read is skipped the same way: there
|
|
971
|
+
// is no folder to ask about.
|
|
972
|
+
//
|
|
973
|
+
// NOT `authoring`, which is only the run that OPENED the lane. The test-first
|
|
974
|
+
// skills re-run `up` without `--docs` for the setup commands before the spec
|
|
975
|
+
// is written, and that run resolves — off the registry record the lane left —
|
|
976
|
+
// so `authoring` is false while `spec.path` is still null. Asking git about a
|
|
977
|
+
// null path threw, which is a crash rather than a refusal: the condition is
|
|
978
|
+
// whether there is a document, and it always was.
|
|
979
|
+
const noDocument = authoring || !spec.path
|
|
980
|
+
const upOnFork = noDocument
|
|
981
|
+
? { onFork: undefined, foundOn: null }
|
|
982
|
+
: specOnForkPoint(dir, upGit, spec)
|
|
983
|
+
const upSpecUntracked = noDocument ? false : specIsUntracked(dir, upGit, spec)
|
|
943
984
|
|
|
944
985
|
// Trust the shared worktree root so edits into the freshly-provisioned worktree
|
|
945
986
|
// don't prompt. One absolute entry (the root) covers every spec; self-heals on
|
|
@@ -1031,9 +1072,15 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
1031
1072
|
// Positive, so an operator reading the plan knows the missing spec folder is
|
|
1032
1073
|
// the lane working rather than something the engine failed to find.
|
|
1033
1074
|
if (authoring) {
|
|
1034
|
-
out.push(' authoring: no spec document yet —
|
|
1075
|
+
out.push(' authoring: no spec document yet — the skill writes it in the worktree')
|
|
1035
1076
|
out.push(` recorded: added to ${config.registry} until the spec is written`)
|
|
1036
1077
|
}
|
|
1078
|
+
// Only where one was NAMED. The fork point is otherwise base HEAD, which is
|
|
1079
|
+
// the unremarkable case, and a line asserting it on every provision would
|
|
1080
|
+
// make the one run that forked from a tag look like every other.
|
|
1081
|
+
if (authoring && flags.from !== undefined) {
|
|
1082
|
+
out.push(` fork: ${flags.from} (the branch forks from here, not from base)`)
|
|
1083
|
+
}
|
|
1037
1084
|
if (trust.reason === 'malformed') {
|
|
1038
1085
|
out.push(
|
|
1039
1086
|
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
@@ -1050,21 +1097,20 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
1050
1097
|
out.push('')
|
|
1051
1098
|
out.push(' to provision, run:')
|
|
1052
1099
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1100
|
+
// Everything after the fork runs in the worktree, under one heading, in the
|
|
1101
|
+
// order each step depends on the last: seeds (the env file may be one), the
|
|
1102
|
+
// env file, setup (which may derive values into it), and only then the stack.
|
|
1103
|
+
const worktreeSteps = [
|
|
1104
|
+
...plan.seedCommands,
|
|
1105
|
+
...(plan.envCommands || []),
|
|
1106
|
+
...plan.setupCommands,
|
|
1107
|
+
...(plan.dockerCommands || []),
|
|
1108
|
+
]
|
|
1056
1109
|
if (worktreeSteps.length) {
|
|
1057
1110
|
out.push('')
|
|
1058
1111
|
out.push(' then, in the worktree, run:')
|
|
1059
1112
|
for (const cmd of worktreeSteps) out.push(` ${cmd}`)
|
|
1060
1113
|
}
|
|
1061
|
-
if (plan.envContents) {
|
|
1062
|
-
out.push('')
|
|
1063
|
-
out.push(` write ${config.docker.envFile} in the worktree:`)
|
|
1064
|
-
for (const line of plan.envContents.replace(/\n$/, '').split('\n')) {
|
|
1065
|
-
out.push(` ${line}`)
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
1114
|
process.stdout.write(out.join('\n') + '\n')
|
|
1069
1115
|
}
|
|
1070
1116
|
|
|
@@ -1084,6 +1130,38 @@ function speclessMap(dir, config) {
|
|
|
1084
1130
|
}
|
|
1085
1131
|
|
|
1086
1132
|
// A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
|
|
1133
|
+
/**
|
|
1134
|
+
* Is `ref` a fork point git will accept? Returns a refusal string, or null.
|
|
1135
|
+
*
|
|
1136
|
+
* THIS IS AN ACCUSATION, so it fires only on a positive signal
|
|
1137
|
+
* (`.claude/rules/negative-checks.md` rule 1). `rev-parse --verify` returning
|
|
1138
|
+
* nothing means one of two very different things — the ref does not exist, or
|
|
1139
|
+
* git could not answer at all — and only the first is evidence. So the control
|
|
1140
|
+
* question is asked first: if git cannot even name its own directory, the ref
|
|
1141
|
+
* is passed straight through and `git worktree add` refuses it far more loudly
|
|
1142
|
+
* than a planner guessing from an absence ever could (rule 4).
|
|
1143
|
+
*
|
|
1144
|
+
* WHAT WOULD FOOL THIS: a ref that exists but is not a commit — an annotated
|
|
1145
|
+
* tag object is peeled by `^{commit}`, so that case is covered, but a blob or
|
|
1146
|
+
* tree ref would be rejected here and rejected by `git worktree add` too. Being
|
|
1147
|
+
* wrong that way costs a refusal on something that could never have worked.
|
|
1148
|
+
*/
|
|
1149
|
+
function badForkRef(cwd, ref) {
|
|
1150
|
+
const git = gitReader(cwd)
|
|
1151
|
+
if (!ref) {
|
|
1152
|
+
return 'spec-env up: --from needs a ref — the tag or commit to fork from.\n'
|
|
1153
|
+
}
|
|
1154
|
+
// The control question. No repo, no git, an unreadable directory — all land
|
|
1155
|
+
// here, and none of them is evidence about the ref.
|
|
1156
|
+
if (git(['rev-parse', '--git-dir']) === null) return null
|
|
1157
|
+
if (git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) !== null) return null
|
|
1158
|
+
return (
|
|
1159
|
+
`spec-env up: --from ${ref} — git does not know that ref here, so the ` +
|
|
1160
|
+
'worktree would fork from somewhere else without saying so.\n' +
|
|
1161
|
+
' check the tag exists (git tag --list), or fetch it first.\n'
|
|
1162
|
+
)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1087
1165
|
function gitReader(cwd) {
|
|
1088
1166
|
return (argv) => {
|
|
1089
1167
|
try {
|
|
@@ -5016,6 +5094,7 @@ async function specEnv(rest) {
|
|
|
5016
5094
|
else if (args[i] === '--phase') flags.phase = args[++i]
|
|
5017
5095
|
else if (args[i] === '--record-primary') flags.recordPrimary = true
|
|
5018
5096
|
else if (args[i] === '--docs') flags.docs = true
|
|
5097
|
+
else if (args[i] === '--from') flags.from = args[++i]
|
|
5019
5098
|
else if (args[i] === '--session') flags.session = args[++i]
|
|
5020
5099
|
else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
|
|
5021
5100
|
else positional.push(args[i])
|
package/src/env/provision.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Pure provisioning planner for `spec-env up`.
|
|
5
5
|
*
|
|
6
6
|
* Given a resolved spec and its allocated slot, `planUp` returns the exact
|
|
7
|
-
* side-effecting commands the `/spec-env` skill runs (`git worktree add`,
|
|
8
|
-
* `docker compose up`) and the rendered `.env` contents — but performs no side
|
|
7
|
+
* side-effecting commands the `/spec-env` skill runs (`git worktree add`, the
|
|
8
|
+
* env file, `docker compose up`) and the rendered `.env` contents — but performs no side
|
|
9
9
|
* effects itself. The caller (the CLI) reads/allocates the
|
|
10
10
|
* registry and passes the slot; this stays deterministic and unit-testable with
|
|
11
11
|
* no live git/docker.
|
|
@@ -73,6 +73,50 @@ function seedCommandFor(file, mode) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
// Single-quote a value for POSIX sh: the only character that needs escaping
|
|
77
|
+
// inside single quotes is the single quote itself.
|
|
78
|
+
function shq(value) {
|
|
79
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build one idempotent POSIX-sh command that writes the spec's env file in the
|
|
84
|
+
* current worktree (the cwd when the skill runs it).
|
|
85
|
+
*
|
|
86
|
+
* It was prose once — "write <envFile> in the worktree:" — which left the write
|
|
87
|
+
* to whoever read the plan, and a secrets guard that blocks any tool call naming
|
|
88
|
+
* `.env` refused it. As a command it is one more step the plan runs.
|
|
89
|
+
*
|
|
90
|
+
* Idempotent and additive: it `touch`es the file and appends each key only when
|
|
91
|
+
* no line already sets it, so a re-run changes nothing and a file `setup` has
|
|
92
|
+
* already written to keeps every value it holds — including a deliberate
|
|
93
|
+
* `PORT_OFFSET` of its own.
|
|
94
|
+
*
|
|
95
|
+
* A SYMLINK IS REFUSED, loudly. Seeding links gitignored files from the primary
|
|
96
|
+
* checkout, so a seeded env file is the primary checkout's own; appending
|
|
97
|
+
* through it would write this spec's port block into the main stack's
|
|
98
|
+
* configuration. It runs after the seeds precisely so that collision is caught
|
|
99
|
+
* rather than hidden. The fix is a `docker.envFile` naming a file of its own.
|
|
100
|
+
*/
|
|
101
|
+
function envFileCommandFor(envFile, projectName, offset) {
|
|
102
|
+
const f = shq(envFile)
|
|
103
|
+
const ensure = (key, value) =>
|
|
104
|
+
`{ grep -q '^${key}=' ${f} || echo ${shq(`${key}=${value}`)} >> ${f}; }`
|
|
105
|
+
return (
|
|
106
|
+
`if [ -L ${f} ]; then echo ${shq(
|
|
107
|
+
`env file ${envFile} is a symlink — docker.envFile must name a file of its ` +
|
|
108
|
+
"own, not a seeded one; refusing to write this spec's port block through it",
|
|
109
|
+
)} >&2; exit 1; fi; ` +
|
|
110
|
+
`touch ${f} && ` +
|
|
111
|
+
// A file without a trailing newline would glue the first appended key onto
|
|
112
|
+
// its last line.
|
|
113
|
+
`{ [ ! -s ${f} ] || [ -z "$(tail -c 1 ${f})" ] || echo >> ${f}; } && ` +
|
|
114
|
+
`${ensure('COMPOSE_PROJECT_NAME', projectName)} && ` +
|
|
115
|
+
`${ensure('PORT_OFFSET', offset)} && ` +
|
|
116
|
+
`echo ${shq(`env file ${envFile}: ready`)}`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
76
120
|
// Cap a path list in a message: enough to recognise, not a wall of text.
|
|
77
121
|
function listPaths(paths) {
|
|
78
122
|
const shown = paths.slice(0, 5).join(', ')
|
|
@@ -294,8 +338,10 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
|
|
|
294
338
|
* @param {object} config normalised env config.
|
|
295
339
|
* @param {object} opts { docs } — documents mode (see below). Optional.
|
|
296
340
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
297
|
-
* envContents, commands, seedCommands,
|
|
298
|
-
* setupCommands, docs, attached }
|
|
341
|
+
* envContents, commands, seedCommands, envCommands,
|
|
342
|
+
* setupCommands, dockerCommands, docs, attached }
|
|
343
|
+
*
|
|
344
|
+
* The worktree steps run in that order: seeds, env file, setup, compose.
|
|
299
345
|
*/
|
|
300
346
|
function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
301
347
|
const { slot, attached } = alloc
|
|
@@ -367,12 +413,24 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
|
367
413
|
? `git worktree add ${spec.worktreePath} ${spec.branch}`
|
|
368
414
|
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
369
415
|
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
416
|
+
// The stack comes up LAST, in the worktree: after the seeds, after the env
|
|
417
|
+
// file, after `setup` — which may derive values into that file. It once ran
|
|
418
|
+
// from the primary checkout before the worktree existed, so compose read the
|
|
419
|
+
// primary checkout's env file, fell back to its defaults and collided with the
|
|
420
|
+
// main stack. `--env-file` is always passed: with the default `.env` it is what
|
|
421
|
+
// compose would have found anyway, and with any other name it is the only way
|
|
422
|
+
// compose finds it.
|
|
423
|
+
const envFile = (config.docker && config.docker.envFile) || '.env'
|
|
424
|
+
const envCommands = wantsDocker
|
|
425
|
+
? [`${guard}; ${envFileCommandFor(envFile, spec.projectName, offset)}`]
|
|
426
|
+
: []
|
|
427
|
+
const dockerCommands = wantsDocker
|
|
428
|
+
? [
|
|
429
|
+
`${guard}; docker compose` +
|
|
430
|
+
composeFileArg(config, ctx ? ctx.composeFilePresent : undefined) +
|
|
431
|
+
` --env-file ${shq(envFile)} --project-name ${spec.projectName} up -d`,
|
|
432
|
+
]
|
|
433
|
+
: []
|
|
376
434
|
|
|
377
435
|
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
378
436
|
// uncommitted spec would produce a branch missing the spec it is for — this is
|
|
@@ -398,11 +456,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
|
398
456
|
envContents,
|
|
399
457
|
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
400
458
|
seedCommands: gate.blocked ? [] : seedCommands,
|
|
459
|
+
envCommands: gate.blocked ? [] : envCommands,
|
|
401
460
|
// Documents mode drops these, and keeps the seed commands above. The two are
|
|
402
461
|
// not the same kind of thing: seeding links the gitignored files a worktree
|
|
403
462
|
// has none of — cheap, and a spec author may well want `.env` present —
|
|
404
463
|
// while `setup` is the expensive half that exists to make the tree run.
|
|
405
464
|
setupCommands: gate.blocked || docs ? [] : setupCommands,
|
|
465
|
+
dockerCommands: gate.blocked ? [] : dockerCommands,
|
|
406
466
|
// Reported so a caller reading `--json` can tell a documents worktree from a
|
|
407
467
|
// full one POSITIVELY, rather than inferring it from what is missing. An
|
|
408
468
|
// empty `setupCommands` also describes a project that configured no setup
|
|
@@ -481,6 +541,7 @@ module.exports = {
|
|
|
481
541
|
planCheckoutUp,
|
|
482
542
|
planSpecCommit,
|
|
483
543
|
seedCommandFor,
|
|
544
|
+
envFileCommandFor,
|
|
484
545
|
worktreeCd,
|
|
485
546
|
resolveStack,
|
|
486
547
|
composeFileArg,
|
package/src/env/teardown.js
CHANGED
|
@@ -94,6 +94,10 @@ function planDown(spec, config, flags, ctx) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// --- docker compose down (drop volumes unless kept) ---
|
|
97
|
+
// Run from the primary checkout with no `--env-file`, unlike `up`, and on
|
|
98
|
+
// purpose: compose finds the stack's containers, networks and volumes by the
|
|
99
|
+
// project label `--project-name` gives it, so it needs nothing from the env
|
|
100
|
+
// file — which may already be gone with a half-removed worktree.
|
|
97
101
|
if (wantsDocker) {
|
|
98
102
|
const base =
|
|
99
103
|
`docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
|