@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.
@@ -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
- "envFile": ".env", // written into the worktree
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",
@@ -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 = data.files.map(function (f) {
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
- Object.keys(n.dirs).sort().forEach(function (name) {
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(data.files)))
1795
+ tree.appendChild(renderTree(buildTree(files)))
1754
1796
  document.getElementById('tree-summary').textContent =
1755
- 'Files (' + data.files.length + ')'
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
- The engine resolves a spec by its folder, so seed a **minimal stub** for it to
51
- provision from — you'll flesh it out in §4:
52
-
53
- - From the base branch (`main`), create
54
- `specs/in-progress/bug-<name>/00-overview.md` with just the header block and the
55
- `## Symptom` you established above.
56
- - Run `skitterspec spec-env up bug-<name>` (the `spec-env` CLI engine). It prints
57
- the `git worktree add … -b bug/<name>` command (a branch forked from `main`),
58
- the worktree path, and any `in the worktree, run:` bootstrap steps.
59
- - Run the printed commands in order. The plan **commits the stub first** — the
60
- worktree forks from `main`'s last commit, so the stub has to be in it — and
61
- then adds the worktree. Nothing to move afterwards: the spec is already there.
62
- The commit is planned, not silent; it appears in the printed plan above the
63
- `git worktree add`, and `spec-env up` refuses outright if anything *other* than
64
- this spec is uncommitted.
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. Act on the worktree with absolute paths /
75
- `git -C <worktreePath>`, or open a session rooted at the printed path. `main`
76
- changes only when the branch merges (at `/spec-complete`).
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
- Fill in the spec's entry point `00-overview.md`. **When isolated**, you already
93
- seeded this stub in §2 and moved it into the worktree — flesh it out there.
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. Seed the stub, then provision the worktree from the tag
59
-
60
- The engine forks the worktree from the spec's `Base version`, so the stub — with
61
- that header — must exist **before** `spec-env up`:
62
-
63
- - From the base branch (`main`), create
64
- `specs/in-progress/hotfix-<name>/00-overview.md` with the header block
65
- (including `> **Type:** Hotfix` and `> **Base version:** <tag>`) and the
66
- `## Symptom` you established. It starts in `in-progress` — work begins now.
67
- - Run `skitterspec spec-env up hotfix-<name>`. It prints a `git worktree add …
68
- -b hotfix/<slug> <tag>` command (the branch forks from **the tag**, not
69
- `main`), the worktree path, and any `in the worktree, run:` bootstrap steps.
70
- - Run the printed `git worktree add`, then **move the stub across yourself.**
71
- This is where a hotfix differs from `/spec-bug`, which no longer needs the move:
72
- that skill's worktree forks from `main`, so committing the stub puts it there,
73
- while **this worktree is checked out at the tag** — a commit on `main` is not in
74
- it and never will be. The move is not redundant here; keep it.
75
- **Create the destination bucket first:**
76
-
77
- ```
78
- mkdir -p <worktreePath>/specs/in-progress
79
- mv specs/in-progress/hotfix-<name> <worktreePath>/specs/in-progress/
80
- ```
81
-
82
- The `mkdir -p` is not belt-and-braces. Git does not store empty directories, so
83
- `specs/in-progress/` is **absent** from the worktree whenever nothing was in
84
- progress at that point in history — and here that point is an
85
- **old release tag**, where it is absent more often than not. `mv` into a
86
- missing destination renames your spec folder **to** `specs/in-progress`,
87
- silently: the spec's files end up one level too high, `00-overview.md` sits
88
- where the bucket should be, and every later step still appears to work until
89
- something cannot find the spec. Confirm the result before carrying on — you
90
- want `<worktreePath>/specs/in-progress/hotfix-<name>/00-overview.md`. - **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
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. Act with absolute paths /
100
- `git -C <worktreePath>`, or open a fresh session rooted there. `main` changes
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
- Flesh out `00-overview.md` in the worktree (you seeded the stub in §3). A hotfix
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.1",
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
- // An authoring run does not ask whether the spec is on the fork point — it is
937
- // known to be nowhere, and that is the healthy state of the lane, not the
938
- // "would fork without the spec it is for" refusal. `onFork: undefined` is the
939
- // gate's cannot-tell branch, which allows (negative-checks rule 4). The
940
- // untracked read is skipped the same way: there is no folder to ask about.
941
- const upOnFork = authoring ? { onFork: undefined, foundOn: null } : specOnForkPoint(dir, upGit, spec)
942
- const upSpecUntracked = authoring ? false : specIsUntracked(dir, upGit, spec)
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 — /spec writes it in the worktree')
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
- // Seed files first (setup may depend on them), then the setup commands —
1054
- // both run in the worktree, under one heading.
1055
- const worktreeSteps = [...plan.seedCommands, ...plan.setupCommands]
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])
@@ -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
- if (wantsDocker) {
371
- commands.push(
372
- `docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
373
- ` --project-name ${spec.projectName} up -d`,
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,
@@ -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)}` +