@skitterbyte/skitterspec 22.3.2 → 22.5.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/README.md CHANGED
@@ -164,7 +164,8 @@ live: off — /spec-live to put it live
164
164
  `local` and `network` are two doors into one room — the same server, the same
165
165
  waiting verdict — so the wait covers both. `remote` is a published page, which
166
166
  is a separate store: a verdict there needs `/spec-reviewed`, and it is off until
167
- you type `/spec-remote-review`. The `live:` line is the other way to judge a
167
+ you type `/spec-remote-review`, which permits **that spec** to publish, publishes
168
+ the waiting page and re-shows the links. The `live:` line is the other way to judge a
168
169
  change: whether it is also **running**, and the page has a button to put it
169
170
  there.
170
171
 
@@ -182,7 +183,7 @@ All optional, in `specs/.core/env.config.json`:
182
183
  |-----|---------|------|
183
184
  | `review.serve` | `"always"` | whether a render stands the local server up. `"never"` gives you a `file://` page, whose buttons copy a command instead of sending |
184
185
  | `review.allowNetwork` | `true` | whether that server binds every interface, so the page opens on your phone |
185
- | `review.allowRemote` | `false` | whether publishing is permitted at all. `/spec-remote-review` toggles it; it permits publishing, it does not publish |
186
+ | `review.allowRemote` | `false` | whether publishing is permitted for every spec — the project default. `/spec-remote-review` permits one spec at a time in a gitignored sidecar and never edits this key (`review allow remote --project` does) |
186
187
  | `review.required` | `true` | whether a finished phase owes a verdict |
187
188
  | `review.commitWith` | `"/commit"` | the skill a committing verdict hands off to |
188
189
  | `review.reader` | `"detect"` | where you are reading — `local`, `remote`, or detect it |
@@ -1,22 +1,37 @@
1
1
  ---
2
- description: Permit or forbid publishing a review for a reader off your network — bare toggles it, `on`/`off` set it explicitly
2
+ description: Permit or forbid publishing this spec's review for a reader off your network — bare toggles it, `on`/`off` set it explicitly
3
3
  argument-hint: "[on | off]"
4
4
  allowed-tools: Bash({{exec}} skitterspec spec-env review allow remote:*)
5
5
  disable-model-invocation: true
6
6
  ---
7
7
  !`{{exec}} skitterspec spec-env review allow remote --set "$ARGUMENTS"`
8
8
 
9
- Relay the engine output above verbatim. Add nothing and run nothing else.
9
+ Relay the engine output above verbatim. Add nothing to it.
10
+
11
+ **Then one thing, and only when the engine asked for it.** Where the output
12
+ carries a `review:` and a `publish:` line, typing this command **is** the ask
13
+ to publish that page: the banner named this command beside a review the reader
14
+ could not reach, so the link is what they are waiting for. Publish it exactly
15
+ as `/spec-diff` §6 describes (run the `publish:` command, publish the copy it
16
+ names with a store, write the URL file), then re-emit the review banner with
17
+ all three tiers, `remote` now carrying the URL. A wait already running keeps
18
+ running; do not restart it or the server. If the harness refuses the publish,
19
+ say so in one line and keep the banner as it was, because local and network
20
+ still work.
21
+
22
+ With no `publish:` line, run nothing else. Nothing has a page to publish, or
23
+ the tier was turned off.
10
24
 
11
25
  **Bare toggles.** That is the common case: you are looking at a render whose
12
26
  `remote:` line says which way it currently is, and what you want is the other
13
27
  one. `on` and `off` are there for when you are not looking at it.
14
28
 
15
- **It permits publishing; it publishes nothing.** A published page is one
16
- skitterspec cannot delete, so publishing stays an explicit ask
17
- (`/spec-diff` §6) — and a verdict pressed on one needs `/spec-reviewed`, because
18
- nothing pushes from an artifact's store into a conversation.
29
+ **It is per spec.** The engine resolves the spec from the worktree you are
30
+ standing in and records the permission in the gitignored review store, so
31
+ the base branch is untouched and no other spec may publish because of it.
32
+ `skitterspec spec-env review allow remote --project` is the committed,
33
+ every-spec form, for a team that wants it.
19
34
 
20
- **It writes a committed file.** `specs/.core/env.config.json` in the primary
21
- checkout, whichever tree you ran this from — so it changes for everyone who
22
- pulls and leaves that tree dirty. The engine says so; do not soften it.
35
+ **A published page is one skitterspec cannot delete**, and a verdict pressed
36
+ on one needs `/spec-reviewed`, because nothing pushes from an artifact's store
37
+ into a conversation.
@@ -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",
@@ -132,6 +144,23 @@ through to a documented conservative default; see the field notes below.
132
144
  // redundant). Example: ["pnpm install --frozen-lockfile"].
133
145
  "setup": [],
134
146
 
147
+ // Commands to run in the PRIMARY checkout after a land — the counterpart of
148
+ // `setup`, for whatever landed code needs before the base branch is usable
149
+ // locally (e.g. ["pnpm db:data-migration"] for imperative data migrations
150
+ // your deploy runs and nothing runs on your dev DB). An array, run in order;
151
+ // [] (or absent) = none, and every plan prints exactly what it always did.
152
+ // `spec-env integrate` prints them after the fast-forward (worktree and
153
+ // checkout mode), and also after "already landed" — a land on another
154
+ // machine leaves them pending here. `spec-env hotfix land` prints them
155
+ // after the cherry-pick onto the base branch, and not at all when
156
+ // `hotfix.cherryPickMain` is false, since the base gained nothing.
157
+ // The engine only plans: /spec-complete, /spec-to-main and /no-spec run
158
+ // them (a /spec documents-only land does not). They stop at the first
159
+ // failure, report it as a caveat, and never undo the land or block
160
+ // teardown. Literal strings — tokens are NOT expanded, because they run on
161
+ // the base branch where a spec's {slug} or {portOffset} mean nothing.
162
+ "afterLand": [],
163
+
135
164
  // Host dev servers `spec-env dev up <spec>` starts on the spec's port block
136
165
  // (for apps that run via `pnpm dev` on the host, not inside the Docker stack).
137
166
  // An array so UI + API (or more) are supervised independently; [] = none.
@@ -329,10 +358,14 @@ through to a documented conservative default; see the field notes below.
329
358
  // page opens on your phone, or loopback only. THIS IS WHAT
330
359
  // CHOOSES THE BIND; `reader` no longer does. Default: true,
331
360
  // which matches what the engine already did.
332
- // `allowRemote` — whether PUBLISHING is permitted at all. It permits it; it
333
- // publishes nothing. Default: FALSE, because a published
334
- // page is one skitterspec cannot delete, so it must never
335
- // happen unasked.
361
+ // `allowRemote` — whether PUBLISHING is permitted for EVERY spec. It
362
+ // permits it; it publishes nothing. Default: FALSE,
363
+ // because a published page is one skitterspec cannot
364
+ // delete, so it must never happen unasked. This is the
365
+ // PROJECT DEFAULT: `/spec-remote-review` permits one spec
366
+ // at a time, in a gitignored sidecar beside its page, and
367
+ // never edits this file — `review allow remote --project`
368
+ // is what does.
336
369
  //
337
370
  // `local` and `network` are two doors into ONE ROOM — the page POSTs to
338
371
  // `location.pathname`, so both reach the same server and the same waiting
@@ -29,8 +29,12 @@ finding one. They are marked `disable-model-invocation`, meaning
29
29
  **only you can run them** — a skill that wants one will tell you to type it
30
30
  rather than invoking it.
31
31
 
32
- **`/spec-remote-review`** is the newest and the smallest: it toggles
33
- `review.allowRemote`, and takes `on`/`off` when you would rather say which.
32
+ **`/spec-remote-review`** is the newest and the smallest: it toggles whether
33
+ **this spec** may publish, and takes `on`/`off` when you would rather say which.
34
+ The permission is per worktree, kept in the gitignored review store, so the base
35
+ branch is never dirtied and no other spec gains it; `review.allowRemote` in the
36
+ committed config is the project default beneath it. Turned on beside a page
37
+ that already exists, it is the ask to publish that page and re-show the stack.
34
38
  It exists because a render's `remote:` line has to name something a person can
35
39
  act on, and `skitterspec spec-env review allow remote` is not what anyone
36
40
  reconstructs from a page they are reading on a phone. Making the line
@@ -156,6 +156,14 @@ three, in that order — never the bare `page:` path.
156
156
  **forgets the specless record**, which is what stops a torn-down name going on
157
157
  answering to every verb with a path that is no longer there.
158
158
 
159
+ **Between the two, run the after-land commands.** Where the `integrate` plan
160
+ ends with a `then, in the primary checkout (<path>), run:` section — the
161
+ project's `afterLand` — run those commands there, in order, once the
162
+ fast-forward has succeeded, and on an already-landed no-op too. A lockfile
163
+ refresh is exactly the land that needs them.
164
+ **Stop at the first non-zero exit**: the run becomes `⚠️`, naming the command that failed and the ones that
165
+ did not run, and it **never undoes the land and never stops the `down`**.
166
+
159
167
  **If the rebase conflicts, stop.** `git rebase --abort`, leave the commit on
160
168
  the branch, tear nothing down, and report `❌` with the conflict — there is a
161
169
  standing worktree, which is what separates `❌` from `⏸`.
@@ -181,7 +189,9 @@ the shape; this section carries only what is specific here.
181
189
  **Verdicts**
182
190
 
183
191
  - `✅` — the work is done, the tests are green, and the verdict was acted on.
184
- - `⚠️` — done and green, with something worth knowing.
192
+ - `⚠️` — done and green, with something worth knowing — an after-land command
193
+ that failed, for one. `Notes` carries what the after-land commands ran, or
194
+ where they stopped; nothing about them when none are configured.
185
195
  - `❌` — the tests are red, or the land failed part-way. Quote the failure and
186
196
  say what is standing.
187
197
  - `⏸` — refused before acting: no name given, the name is already a spec, the
@@ -363,6 +363,12 @@ It plans a rebase onto the base branch in the worktree, then a
363
363
  refuses on a dirty tree, which is correct here: the commit ran first, so anything
364
364
  still uncommitted at this point is not this spec's and the land must not proceed.
365
365
 
366
+ **Do not run its after-land section.** Where the plan ends with
367
+ `then, in the primary checkout (<path>), run:`, those are the project's
368
+ `afterLand` commands — a data migration, an install — and they exist for code
369
+ that landed. This land is documents only, so it changes nothing they would
370
+ catch up on; `/spec-complete`, `/spec-to-main` and `/no-spec` run them.
371
+
366
372
  **If the rebase conflicts, stop and say so.** It is unlikely on a
367
373
  documents-only branch and not impossible — two specs authored at once can both
368
374
  touch `specs/.core/`. Run `git rebase --abort`, leave the commit sitting on the
@@ -113,16 +113,21 @@ the shape; this section carries only what is specific here.
113
113
 
114
114
  - `✅` — every phase done, tests green, landed, torn down.
115
115
  - `⚠️` — finished, with something worth knowing: teardown declined, a tracker
116
- refresh that failed, a hotfix tag still waiting to be pushed.
116
+ refresh that failed, a hotfix tag still waiting to be pushed, an after-land
117
+ command that failed.
117
118
  - `❌` — it acted and stopped part-way — a conflicted rebase, a failed
118
119
  cherry-pick, a red base after landing. Say what state the repo is in and quote
119
120
  the failure; there is a mess to clear and the reader needs to know where.
120
121
  - `⏸` — phases are unfinished, tests are red, or the tree is dirty. Nothing
121
122
  moved.
122
123
 
123
- **Fields:** `Tracker` · `Spec` · `Tests` · `Landed` · `Worktree` ·
124
+ **Fields:** `Tracker` · `Spec` · `Tests` · `Notes` · `Landed` · `Worktree` ·
124
125
  `Follow-ups` · `Next`
125
126
 
127
+ `Notes` carries the after-land commands step 6 ran, and where — or, on a
128
+ failure, the command that failed and the ones that did not run. A project with
129
+ no `afterLand` says nothing about it.
130
+
126
131
  **Report this spec and no other.** A completion is the moment it is most
127
132
  tempting to survey what is left — what else is in flight, what is in the
128
133
  backlog, which worktrees are still standing — and it is the moment it helps
@@ -167,8 +172,12 @@ Use the hotfix landing instead of the integrate steps below:
167
172
  cherry-pick --abort` there, relay the conflict, and **stop** — don't offer
168
173
  teardown.
169
174
  On a **no-op** ("nothing to land"), say so and continue.
170
- 3. **Re-test on `main`** after the cherry-pick — it must be **green**.
171
- 4. **Report** the deploy tag(s) and the `main` cherry-pick. It **never pushes** —
175
+ 3. **Run the after-land commands** when the plan ends with a
176
+ `then, in the primary checkout (<path>), run:` section — exactly as step 3
177
+ of 6-feature/bug below says. `hotfix land` prints it only when it
178
+ cherry-picks onto `main`, so a prod-only land or a no-op has none.
179
+ 4. **Re-test on `main`** after the cherry-pick — it must be **green**.
180
+ 5. **Report** the deploy tag(s) and the `main` cherry-pick. It **never pushes** —
172
181
  remind the user to `git push origin <deploy-tag>` to deploy. Then teardown
173
182
  (step 7) applies: a tagged hotfix branch tears down with **no `--force`**.
174
183
 
@@ -207,9 +216,20 @@ is finished — e.g. to run a later phase in CI or a shared test env? Use
207
216
  `git -C <worktree> rebase --abort`, relay the conflict, and **stop** — leave it
208
217
  to the user; do not tear anything down.
209
218
  On a **no-op** ("already landed"), just say so and continue.
210
- 3. **Re-test on base.** Run the project's test command from the primary checkout;
219
+ 3. **Run the after-land commands.** Where the plan ends with a
220
+ `then, in the primary checkout (<path>), run:` section — the project's
221
+ `afterLand`, such as a data migration — run those commands in that
222
+ directory, in order, after the fast-forward succeeds. **Also on the no-op**:
223
+ the engine prints them there deliberately, because a land made on another
224
+ machine leaves them pending here. **Stop at the first non-zero exit** — a
225
+ later command may depend on it — and carry on with the rest of the skill:
226
+ a failure makes the run `⚠️`, naming the command that failed and the ones
227
+ that did not run, and it
228
+ **never undoes the land and never blocks teardown** (step 7). The land is correct; catching up is the operator's.
229
+ No section, nothing to run, and nothing to say about it.
230
+ 4. **Re-test on base.** Run the project's test command from the primary checkout;
211
231
  it must be **green** before you call the landing done.
212
- 4. **Report** the landing (base branch, fast-forward result). It **never pushes** —
232
+ 5. **Report** the landing (base branch, fast-forward result). It **never pushes** —
213
233
  mention the user can `git push` the base branch themselves.
214
234
 
215
235
  ## 7. Tear down the environment (opt-in, only if configured)
@@ -221,7 +241,9 @@ teardown is folded in here).
221
241
 
222
242
  **The precondition is that the work actually landed.** Only tear down when step 6
223
243
  completed: it landed (or reported "already landed") **and** the base suite came
224
- back green. A rebase conflict, a work-loss abort or a red suite means step 6 told
244
+ back green. A failed after-land command is **not** a reason to stop here — the
245
+ land stands, and the command is the operator's to re-run from the primary
246
+ checkout, which teardown does not touch. A rebase conflict, a work-loss abort or a red suite means step 6 told
225
247
  you to stop — tear nothing down, because the worktree is where the user picks the
226
248
  problem up. That precondition is what makes a confirmation redundant: by the time
227
249
  you get here the branch is an ancestor of base and the engine's guards have
@@ -363,7 +363,7 @@ still has a decision in front of them and has not made it.
363
363
  |--------|----|------|
364
364
  | `live-on` | commit first (below), then `skitterspec spec-env live take <spec>` | re-render `--branch`, wait again |
365
365
  | `allow-network` | `skitterspec spec-env review allow network` | re-render, wait again |
366
- | `allow-remote` | `skitterspec spec-env review allow remote --set on` | re-render, wait again |
366
+ | `allow-remote` | `skitterspec spec-env review allow remote <spec> --set on` | publish (§6), re-render, wait again |
367
367
 
368
368
  **THERE IS NO `live-off`, and its absence is deliberate.** Putting *this* change
369
369
  live is about the diff on screen; handing the whole instance back to `main` is a
@@ -420,18 +420,22 @@ text, and do not infer an offer that is not there — the boundary that keeps
420
420
  another spec's workbench out of this lives in that field rather than in prose
421
421
  here.
422
422
 
423
- **`allow` writes a committed file, and that is worth a sentence.** It edits
424
- `specs/.core/env.config.json` in the **primary checkout**, so it changes
425
- behaviour for everyone who pulls and leaves that tree dirty — unlike the live
426
- actions, which move a branch and write a gitignored receipt. The engine prints
427
- the absolute path and whose tree it dirtied; relay that rather than letting a
428
- shared setting change land silently. And it is **enable-only**: there is no
423
+ **`allow network` writes a committed file, and that is worth a sentence.** It
424
+ edits `specs/.core/env.config.json` in the **primary checkout**, so it changes
425
+ behaviour for everyone who pulls and leaves that tree dirty — one server binds
426
+ for every spec, so the bind cannot be narrower. The engine prints the absolute
427
+ path; relay that rather than letting a shared setting change land silently.
428
+ **`allow remote` is per spec** and writes only a gitignored sidecar beside the
429
+ page, so the base branch stays clean and no other spec may publish. And it is **enable-only**: there is no
429
430
  action that turns a tier off, because turning `network` off from a page reached
430
431
  over the network kills the page doing the turning.
431
432
 
432
- **`allow remote` permits publishing. It does not publish.** Publishing stays an
433
- explicit ask in every case (§6), because the page it leaves behind is one this
434
- tooling cannot remove.
433
+ **`allow remote` permits publishing. It does not publish by itself.**
434
+ Publishing stays an explicit ask (§6), because the page it leaves behind is one
435
+ this tooling cannot remove. A reader who presses `Allow remote` on the page, or
436
+ types `/spec-remote-review` beside a waiting review, **has** asked: the engine
437
+ names the page on its `review:`/`publish:` lines, and that page is published
438
+ and the stack re-shown. Nothing it does not name is published.
435
439
 
436
440
  ## 3. Gate it on nothing
437
441
 
@@ -87,6 +87,16 @@ nothing else.
87
87
  On a **no-op** ("already landed on `<base>` — nothing to integrate"), just say so
88
88
  and continue — the branch has no commits base doesn't already have.
89
89
 
90
+ **Then run the after-land commands.** Where the plan ends with a
91
+ `then, in the primary checkout (<path>), run:` section — the project's
92
+ `afterLand`, such as a data migration — run those commands in that directory,
93
+ in order, after the fast-forward and **before** the re-test below, which may
94
+ need them. Run them on the no-op too: the engine prints them there because a
95
+ land made elsewhere leaves them pending here.
96
+ **Stop at the first non-zero exit**, and carry on with §5: a failure makes the run `⚠️`, naming the command
97
+ that failed and the ones that did not run, and it **never undoes the land**.
98
+ No section, nothing to run, and nothing to say about it.
99
+
90
100
  ## 5. Re-test on base, then report
91
101
 
92
102
  - Run the project's test command **from the primary checkout** — base must be
@@ -111,12 +121,17 @@ the shape; this section carries only what is specific here.
111
121
  **Verdicts**
112
122
 
113
123
  - `✅` — the branch is on the base and the base is green.
114
- - `⚠️` — landed, with something worth knowing.
124
+ - `⚠️` — landed, with something worth knowing — an after-land command that
125
+ failed, for one.
115
126
  - `❌` — the rebase conflicted, or the base went red after the fast-forward.
116
127
  Quote it; the repo is mid-something and that is what the reader needs.
117
128
  - `⏸` — a dirty worktree, red tests, or no spec to land. Nothing moved.
118
129
 
119
- **Fields:** `Branch` · `Tests` · `Landed` · `Follow-ups` · `Next`
130
+ **Fields:** `Branch` · `Tests` · `Notes` · `Landed` · `Follow-ups` · `Next`
131
+
132
+ `Notes` carries the after-land commands §4 ran, and where — or the one that
133
+ failed and the ones that did not run. Nothing about it when none are
134
+ configured.
120
135
 
121
136
  **No `Spec` field, deliberately.** This skill changes no status and moves no
122
137
  folder, and a `Spec` line here would read as though it had. The spec stays
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.3.2",
3
+ "version": "22.5.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
@@ -94,6 +94,9 @@ const {
94
94
  reviewUrlPath,
95
95
  reviewPublishPath,
96
96
  readReviewUrl,
97
+ readRemoteAllow,
98
+ writeRemoteAllow,
99
+ withSpecRemote,
97
100
  publishedPageNotice,
98
101
  reviewServerNotice,
99
102
  renderReviewFragment,
@@ -1097,21 +1100,20 @@ function specEnvUp(dir, config, specArg, flags = {}) {
1097
1100
  out.push('')
1098
1101
  out.push(' to provision, run:')
1099
1102
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1100
- // Seed files first (setup may depend on them), then the setup commands —
1101
- // both run in the worktree, under one heading.
1102
- const worktreeSteps = [...plan.seedCommands, ...plan.setupCommands]
1103
+ // Everything after the fork runs in the worktree, under one heading, in the
1104
+ // order each step depends on the last: seeds (the env file may be one), the
1105
+ // env file, setup (which may derive values into it), and only then the stack.
1106
+ const worktreeSteps = [
1107
+ ...plan.seedCommands,
1108
+ ...(plan.envCommands || []),
1109
+ ...plan.setupCommands,
1110
+ ...(plan.dockerCommands || []),
1111
+ ]
1103
1112
  if (worktreeSteps.length) {
1104
1113
  out.push('')
1105
1114
  out.push(' then, in the worktree, run:')
1106
1115
  for (const cmd of worktreeSteps) out.push(` ${cmd}`)
1107
1116
  }
1108
- if (plan.envContents) {
1109
- out.push('')
1110
- out.push(` write ${config.docker.envFile} in the worktree:`)
1111
- for (const line of plan.envContents.replace(/\n$/, '').split('\n')) {
1112
- out.push(` ${line}`)
1113
- }
1114
- }
1115
1117
  process.stdout.write(out.join('\n') + '\n')
1116
1118
  }
1117
1119
 
@@ -1733,7 +1735,8 @@ function specEnvIntegrate(dir, config, specArg) {
1733
1735
  }
1734
1736
  if (cplan.noop) {
1735
1737
  process.stdout.write(
1736
- `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
1738
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n` +
1739
+ afterLandSection(cplan.afterLand),
1737
1740
  )
1738
1741
  return
1739
1742
  }
@@ -1741,7 +1744,7 @@ function specEnvIntegrate(dir, config, specArg) {
1741
1744
  ` base: ${base}`, ` branch: ${cplan.branch}`, '',
1742
1745
  ' run these (abort the rebase on conflict):']
1743
1746
  for (const cmd of cplan.commands) cout.push(` ${cmd}`)
1744
- process.stdout.write(cout.join('\n') + '\n')
1747
+ process.stdout.write(cout.join('\n') + '\n' + afterLandSection(cplan.afterLand))
1745
1748
  return
1746
1749
  }
1747
1750
 
@@ -1830,7 +1833,8 @@ function specEnvIntegrate(dir, config, specArg) {
1830
1833
  }
1831
1834
  if (plan.noop) {
1832
1835
  process.stdout.write(
1833
- `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
1836
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n` +
1837
+ afterLandSection(plan.afterLand),
1834
1838
  )
1835
1839
  return
1836
1840
  }
@@ -1844,7 +1848,17 @@ function specEnvIntegrate(dir, config, specArg) {
1844
1848
  out.push('')
1845
1849
  out.push(' run these (abort the rebase on conflict):')
1846
1850
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1847
- process.stdout.write(out.join('\n') + '\n')
1851
+ process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
1852
+ }
1853
+
1854
+ // The `afterLand` commands, as a section printed after a land plan — or after
1855
+ // integrate's already-landed line. Empty string when none apply, so a project
1856
+ // without the key sees exactly the output it always has.
1857
+ function afterLandSection(afterLand) {
1858
+ if (!afterLand || !afterLand.commands || afterLand.commands.length === 0) return ''
1859
+ const out = ['', ` then, in the primary checkout (${afterLand.cwd}), run (stop at the first failure):`]
1860
+ for (const cmd of afterLand.commands) out.push(` ${cmd}`)
1861
+ return out.join('\n') + '\n'
1848
1862
  }
1849
1863
 
1850
1864
  // Land a hotfix: tag the branch with the patch-bumped base tag (the prod deploy
@@ -1932,7 +1946,7 @@ function specEnvHotfix(dir, config, positional, flags) {
1932
1946
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1933
1947
  out.push('')
1934
1948
  out.push(` then push the deploy tag yourself: git push origin ${plan.prodTag}`)
1935
- process.stdout.write(out.join('\n') + '\n')
1949
+ process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
1936
1950
  }
1937
1951
 
1938
1952
  // What the primary checkout has GAINED right now, by content. `dir` is already
@@ -2527,24 +2541,32 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2527
2541
  }
2528
2542
 
2529
2543
  /**
2530
- * `review allow <network|remote> [--off]` — permit or withdraw a review tier.
2544
+ * `review allow <network|remote> [<spec>] [--off | --set <on|off|>] [--project]`
2545
+ * — permit or withdraw a review tier.
2531
2546
  *
2532
- * THE FIRST THING IN THIS ENGINE TO WRITE `env.config.json`, and that is why it
2533
- * is narrow: it reads, sets one key under `review`, and writes back with the
2534
- * indent the file already uses. It never reorders, never adds a key nobody
2535
- * asked for, and never touches another section.
2547
+ * TWO SCOPES, because the two tiers are different kinds of decision.
2548
+ *
2549
+ * `network` is a BIND, and one server binds for every spec at once — so it is a
2550
+ * project setting and lives in the committed `env.config.json`.
2536
2551
  *
2537
- * WHAT WOULD FOOL THIS: a config written with comments. `loadEnvConfig` parses
2538
- * it with `JSON.parse`, so such a file already fails to load and there is no
2539
- * comment-preserving case to protect — but a file indented with anything other
2540
- * than two spaces IS reformatted, which is cosmetic and worth knowing before it
2541
- * shows up in someone's diff.
2552
+ * `remote` PERMITS PUBLISHING, which sends one spec's diff to a third party. It
2553
+ * was project-wide too, and that was bug-remote-review-toggle: typed from a
2554
+ * worktree with a review waiting, it dirtied the base branch's committed config
2555
+ * and permitted every other spec in the repo to publish as well. So it is now
2556
+ * PER SPEC — a sidecar in the gitignored review store, layered over the
2557
+ * committed key by `withSpecRemote`. `--project` keeps the committed write for
2558
+ * a team that wants it everywhere.
2559
+ *
2560
+ * THE COMMITTED WRITE IS NARROW: it reads, sets one key under `review`, and
2561
+ * writes back with two-space indent. It never reorders, never adds a key nobody
2562
+ * asked for, and never touches another section.
2542
2563
  *
2543
- * IT EDITS A COMMITTED FILE. Turning network reviews on for yourself turns them
2544
- * on for everyone who pulls, so the output says so rather than leaving it to be
2545
- * discovered by a colleague's render.
2564
+ * WHAT WOULD FOOL THIS: a config indented with anything other than two spaces
2565
+ * IS reformatted by the committed write — cosmetic, but worth knowing before it
2566
+ * shows up in someone's diff. A config with comments already fails to load, so
2567
+ * there is no comment-preserving case to protect.
2546
2568
  */
2547
- function specEnvReviewAllow(dir, config, tier, flags) {
2569
+ function specEnvReviewAllow(dir, config, tier, specArg, flags) {
2548
2570
  const TIERS = { network: 'allowNetwork', remote: 'allowRemote' }
2549
2571
  const key = TIERS[String(tier || '').trim()]
2550
2572
  if (!key) {
@@ -2557,39 +2579,63 @@ function specEnvReviewAllow(dir, config, tier, flags) {
2557
2579
  process.exitCode = 1
2558
2580
  return
2559
2581
  }
2560
- // THREE WAYS TO SAY WHAT THE TIER SHOULD BE, and only the third is new.
2561
- // Bare `allow <tier>` turns it ON and `--off` turns it off, exactly as they
2562
- // always have — every existing caller and test reads those.
2563
- //
2564
- // `--set` exists because a SLASH COMMAND can only make one static
2565
- // substitution: `/spec-remote-review on` has to reach the engine as the word
2566
- // the person typed, not as a flag the command file worked out. An EMPTY value
2567
- // is the bare form of that command, and it toggles — which is the common case,
2568
- // because a reader flipping a tier is looking at the line that says which way
2569
- // it currently is.
2582
+
2583
+ // WHICH SPEC, for the per-spec scope — resolved before anything is read, so a
2584
+ // refusal changes nothing. An unknown name throws rather than falling back:
2585
+ // permitting the wrong spec to publish looks exactly like permitting the
2586
+ // right one.
2587
+ const perSpec = key === 'allowRemote' && !flags.project
2588
+ let spec = null
2589
+ let out = null
2590
+ if (perSpec) {
2591
+ try {
2592
+ spec = resolveSpecWithWorktree(dir, config, specArg)
2593
+ } catch (err) {
2594
+ process.stdout.write(
2595
+ `spec-env review allow: ${err.message}\n` +
2596
+ ' remote is permitted per spec — name it, or run this from its worktree.\n' +
2597
+ ' --project permits every spec instead (writes the committed env.config.json).\n' +
2598
+ ' Nothing changed.\n',
2599
+ )
2600
+ process.exitCode = 1
2601
+ return
2602
+ }
2603
+ out = reviewOutPath(dir, spec.folder, null)
2604
+ }
2605
+
2606
+ // What the tier is now, through the same precedence the render uses — so a
2607
+ // toggle means "the other thing the render showed".
2608
+ const file = path.resolve(dir, 'specs/.core/env.config.json')
2609
+ const committed = () => {
2610
+ try {
2611
+ const now = JSON.parse(fs.readFileSync(file, 'utf8'))
2612
+ return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
2613
+ } catch {
2614
+ return config.review[key]
2615
+ }
2616
+ }
2617
+ const current = () => {
2618
+ if (perSpec) {
2619
+ const own = readRemoteAllow(out)
2620
+ if (own !== null) return own
2621
+ }
2622
+ return Boolean(committed())
2623
+ }
2624
+
2625
+ // THREE WAYS TO SAY WHAT THE TIER SHOULD BE. Bare `allow <tier>` turns it ON
2626
+ // and `--off` turns it off, exactly as they always have. `--set` exists
2627
+ // because a SLASH COMMAND can only make one static substitution: the word the
2628
+ // person typed reaches the engine as a word. An EMPTY value toggles.
2570
2629
  let on = !flags.off
2571
2630
  if (flags.set !== undefined) {
2572
2631
  const said = String(flags.set).trim().toLowerCase()
2573
2632
  if (said === '') {
2574
- // Toggle. Read through the same precedence the report below uses, so
2575
- // "turn it to the other thing" means the other thing the render showed.
2576
- const current = Boolean(
2577
- (() => {
2578
- try {
2579
- const now = JSON.parse(fs.readFileSync(path.resolve(dir, 'specs/.core/env.config.json'), 'utf8'))
2580
- return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
2581
- } catch {
2582
- return config.review[key]
2583
- }
2584
- })(),
2585
- )
2586
- on = !current
2633
+ on = !current()
2587
2634
  } else if (said === 'on' || said === 'off') {
2588
2635
  on = said === 'on'
2589
2636
  } else {
2590
- // Refused by name, like an unknown tier. A misspelt state coerced to a
2591
- // default is the one outcome worth more than a round trip — and the
2592
- // wrong default here opens a port or permits a publish.
2637
+ // Refused by name, like an unknown tier. The wrong default here opens a
2638
+ // port or permits a publish.
2593
2639
  process.stdout.write(
2594
2640
  `spec-env review allow: ${JSON.stringify(String(flags.set))} is not a state — ` +
2595
2641
  'one of on, off, or nothing at all to toggle. Nothing changed.\n',
@@ -2598,58 +2644,77 @@ function specEnvReviewAllow(dir, config, tier, flags) {
2598
2644
  return
2599
2645
  }
2600
2646
  }
2601
- const file = path.resolve(dir, 'specs/.core/env.config.json')
2602
- let parsed
2603
- try {
2604
- parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
2605
- } catch (err) {
2606
- // Cannot tell what is in there, so write nothing. Overwriting a config we
2607
- // could not read is unrecoverable, and the alternative costs one message.
2608
- //
2609
- // NARROWER THAN IT LOOKS: `loadEnvConfig` already refuses the whole
2610
- // `spec-env` command on an unparseable config, naming the file and the
2611
- // parse position — so this branch is reachable only if the file changes
2612
- // between that load and this write. It is kept for that race rather than
2613
- // deleted as dead, and the upstream refusal is what the test asserts.
2614
- process.stdout.write(
2615
- `spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
2616
- 'nothing changed.\n',
2617
- )
2618
- process.exitCode = 1
2619
- return
2647
+
2648
+ const before = current()
2649
+ let wrote
2650
+ if (perSpec) {
2651
+ wrote = writeRemoteAllow(out, on)
2652
+ } else {
2653
+ let parsed
2654
+ try {
2655
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
2656
+ } catch (err) {
2657
+ // Cannot tell what is in there, so write nothing. Reachable only if the
2658
+ // file changes between `loadEnvConfig` and here — that load already
2659
+ // refuses an unparseable config.
2660
+ process.stdout.write(
2661
+ `spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
2662
+ 'nothing changed.\n',
2663
+ )
2664
+ process.exitCode = 1
2665
+ return
2666
+ }
2667
+ parsed.review = { ...(parsed.review || {}), [key]: on }
2668
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
2669
+ wrote = file
2620
2670
  }
2621
- const before = Boolean(
2622
- parsed.review && parsed.review[key] !== undefined ? parsed.review[key] : config.review[key],
2623
- )
2624
- parsed.review = { ...(parsed.review || {}), [key]: on }
2625
- fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
2626
2671
 
2627
2672
  const lines = [
2628
- `spec-env review allow: ${tier} reviews are now ${on ? 'ON' : 'OFF'}` +
2673
+ `spec-env review allow: ${tier} reviews${spec ? ` for ${spec.folder}` : ''} are now ${on ? 'ON' : 'OFF'}` +
2629
2674
  (before === on ? ' (unchanged)' : ''),
2630
- // THE ABSOLUTE PATH, not one relative to `dir`. `dir` is re-anchored to the
2631
- // primary checkout, so run from inside a worktree this writes a file in a
2632
- // DIFFERENT TREE — and a relative path reads as the tree you are standing
2633
- // in. Found by running it from a worktree and reverting the surprise.
2634
- ` wrote: ${file}`,
2635
- ' that is the primary checkout, whichever tree you ran this from, and the file is',
2636
- ' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
2675
+ // THE ABSOLUTE PATH. `dir` is re-anchored to the primary checkout, so run
2676
+ // from a worktree this writes in a different tree — and a relative path
2677
+ // reads as the tree you are standing in.
2678
+ ` wrote: ${wrote}`,
2637
2679
  ]
2680
+ if (perSpec) {
2681
+ lines.push(' this spec only, in the gitignored review store — the committed config is untouched.')
2682
+ } else {
2683
+ lines.push(
2684
+ ' that is the primary checkout, whichever tree you ran this from, and the file is',
2685
+ ' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
2686
+ )
2687
+ }
2638
2688
  if (key === 'allowNetwork') {
2639
2689
  lines.push(
2640
2690
  on
2641
2691
  ? ' the next render binds every interface, so the page opens on your phone.'
2642
2692
  : ' the next render binds 127.0.0.1 only, reachable from this machine.',
2643
2693
  )
2694
+ } else if (!on) {
2695
+ lines.push(
2696
+ ' publishing is no longer permitted; any page already published stays up.\n' +
2697
+ ' turn it back on with: /spec-remote-review',
2698
+ )
2644
2699
  } else {
2645
2700
  lines.push(
2646
- on
2647
- ? ' this PERMITS publishing; it publishes nothing. A published page cannot be\n' +
2648
- ' deleted by skitterspec, and a verdict there needs /spec-reviewed.\n' +
2649
- ' turn it back off with: /spec-remote-review'
2650
- : ' publishing is no longer permitted; any page already published stays up.\n' +
2651
- ' turn it back on with: /spec-remote-review',
2701
+ ' a published page cannot be deleted by skitterspec,\n' +
2702
+ ' and a verdict there needs /spec-reviewed.\n' +
2703
+ ' turn it back off with: /spec-remote-review',
2652
2704
  )
2705
+ // THE REVIEW IT WAS TYPED FOR. The banner names this command beside a page
2706
+ // the reader cannot reach, so turning remote on is the ask to publish THAT
2707
+ // page — and saying nothing about it left the reader with no link at all.
2708
+ // A page on disk is the positive signal; with none there is nothing to
2709
+ // publish, and nothing is named.
2710
+ if (spec && fs.existsSync(out)) {
2711
+ lines.push(
2712
+ ` review: ${spec.folder} has a page — publish it and re-show the stack`,
2713
+ ` publish: skitterspec spec-env review ${spec.folder} --publish-copy`,
2714
+ )
2715
+ } else {
2716
+ lines.push(' this PERMITS publishing; it publishes nothing.')
2717
+ }
2653
2718
  }
2654
2719
  process.stdout.write(lines.join('\n') + '\n')
2655
2720
  }
@@ -2762,6 +2827,9 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2762
2827
  // CHECKOUT, keyed by the spec's folder name; not one byte of it comes from
2763
2828
  // the worktree. `--out` moves the page and its sidecars together.
2764
2829
  const out = reviewOutPath(dir, spec.folder, flags.out)
2830
+ // This spec's own remote permission over the project default — a
2831
+ // per-worktree decision, never the committed config (bug-remote-review-toggle).
2832
+ config = withSpecRemote(config, out)
2765
2833
 
2766
2834
  // The other half of a confirmation: a pass the operator says is not theirs.
2767
2835
  // Left in the store it is reported on every render until they stop reading the
@@ -5065,6 +5133,7 @@ async function specEnv(rest) {
5065
5133
  else if (args[i] === '--stop') flags.stop = true
5066
5134
  else if (args[i] === '--rotate-token') flags.rotateToken = true
5067
5135
  else if (args[i] === '--off') flags.off = true
5136
+ else if (args[i] === '--project') flags.project = true
5068
5137
  else if (args[i] === '--status') flags.status = true
5069
5138
  else if (args[i] === '--port') flags.port = args[++i]
5070
5139
  else if (args[i] === '--host') flags.host = args[++i]
@@ -5230,7 +5299,7 @@ async function specEnv(rest) {
5230
5299
  break
5231
5300
  }
5232
5301
  if (positional[0] === 'allow') {
5233
- specEnvReviewAllow(dir, config, positional[1], flags)
5302
+ specEnvReviewAllow(dir, config, positional[1], positional[2], flags)
5234
5303
  break
5235
5304
  }
5236
5305
  if (positional[0] === 'skip') {
@@ -5250,7 +5319,8 @@ async function specEnv(rest) {
5250
5319
  `Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--run-reviewers] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
5251
5320
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
5252
5321
  ' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
5253
- ' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
5322
+ ' review allow <network|remote> [<spec>] [--off] [--project] permit a review tier\n' +
5323
+ ' (network: env.config.json; remote: this spec only)\n' +
5254
5324
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
5255
5325
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
5256
5326
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
package/src/env/config.js CHANGED
@@ -24,6 +24,9 @@
24
24
  * // setup runs (mode: "symlink" default | "copy"); empty = none
25
25
  * setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
26
26
  * // after `git worktree add` (e.g. install deps); empty = none
27
+ * afterLand:[ "cmd", ... ], // commands run in the PRIMARY checkout after a
28
+ * // land (integrate's ff, hotfix land's main cherry-pick), e.g. a
29
+ * // data migration; literal, no token expansion; empty = none
27
30
  * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
28
31
  * // servers started on the spec's port block (empty = none)
29
32
  * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
@@ -166,6 +169,12 @@ const DEFAULT_CONFIG = Object.freeze({
166
169
  // `git worktree add` (before Docker/dev), on every provision. Array of shell
167
170
  // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
168
171
  setup: Object.freeze([]),
172
+ // Commands run in the primary checkout after a land, printed by `spec-env
173
+ // integrate` (including its already-landed no-op) and by `hotfix land` when
174
+ // it cherry-picks onto the base branch. The skill runs them; the engine only
175
+ // plans. Literal strings — no {slug}/{port} tokens, since they run on the base
176
+ // branch where a spec's tokens mean nothing. Default: none.
177
+ afterLand: Object.freeze([]),
169
178
  // Host dev servers started on the spec's port block by `spec-env dev up`.
170
179
  // Each: { name, command, portVar, health?, frontPort? }. Default: none.
171
180
  dev: Object.freeze([]),
@@ -332,6 +341,7 @@ function defaults() {
332
341
  docker: { ...DEFAULT_CONFIG.docker },
333
342
  seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
334
343
  setup: [],
344
+ afterLand: [],
335
345
  dev: [],
336
346
  proxy: { ...DEFAULT_CONFIG.proxy },
337
347
  registry: DEFAULT_CONFIG.registry,
@@ -542,6 +552,11 @@ function mergeConfig(base, parsed) {
542
552
  base.setup = normalizeSetup(parsed.setup)
543
553
  }
544
554
 
555
+ // Same rule as `setup`: a stray entry is dropped, never thrown on.
556
+ if (Array.isArray(parsed.afterLand)) {
557
+ base.afterLand = normalizeSetup(parsed.afterLand)
558
+ }
559
+
545
560
  if (Array.isArray(parsed.dev)) {
546
561
  base.dev = normalizeDev(parsed.dev)
547
562
  }
package/src/env/hotfix.js CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  const path = require('node:path')
24
+ const { afterLandFor } = require('./integrate.js')
24
25
 
25
26
  /**
26
27
  * Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
@@ -50,7 +51,7 @@ function tagSlug(tag) {
50
51
  * @param {object} config normalised env config (reads `config.hotfix`).
51
52
  * @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
52
53
  * base, extraTargets: string[], existingTags: string[] }
53
- * @returns {object} { blocked, noop, reason, commands, prodTag, targets, branch, base }
54
+ * @returns {object} { blocked, noop, reason, commands, afterLand, prodTag, targets, branch, base }
54
55
  */
55
56
  function planHotfixLand(spec, config, ctx) {
56
57
  const c = ctx || {}
@@ -62,6 +63,7 @@ function planHotfixLand(spec, config, ctx) {
62
63
  noop: false,
63
64
  reason: null,
64
65
  commands: [],
66
+ afterLand: afterLandFor(null),
65
67
  prodTag: null,
66
68
  targets: [],
67
69
  branch,
@@ -122,12 +124,16 @@ function planHotfixLand(spec, config, ctx) {
122
124
  }
123
125
 
124
126
  // 3. Cherry-pick the fix onto the base branch (main) for the next release.
127
+ // Only then does the primary checkout gain anything, so only then do the
128
+ // after-land commands apply — a prod-only land leaves main untouched.
129
+ let afterLand = result.afterLand
125
130
  if (hotfixCfg.cherryPickMain !== false) {
126
131
  commands.push(`git -C ${c.mainRepoPath} cherry-pick ${fixRange}`)
127
132
  targets.push({ kind: 'main', base: c.base })
133
+ afterLand = afterLandFor(config, c.mainRepoPath)
128
134
  }
129
135
 
130
- return { ...result, commands, prodTag, targets, fixRange }
136
+ return { ...result, commands, afterLand, prodTag, targets, fixRange }
131
137
  }
132
138
 
133
139
  module.exports = { bumpPatch, tagSlug, planHotfixLand }
@@ -15,28 +15,32 @@
15
15
  * hands back — so the planner never needs to reason about conflicts.
16
16
  *
17
17
  * @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
18
- * @param {object} config normalised env config (unused today; kept for symmetry).
18
+ * @param {object} config normalised env config (reads `afterLand`).
19
19
  * In CHECKOUT mode there is no worktree: the branch is already checked out in the
20
20
  * primary checkout, so landing is a rebase in place, a switch to base, and the
21
21
  * fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
22
22
  * returns is identical so callers need no second code path.
23
23
  *
24
24
  * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
25
- * @returns {object} { blocked, noop, reason, commands, base, branch }
25
+ * @returns {object} { blocked, noop, reason, commands, afterLand, base, branch }
26
26
  */
27
27
  function planIntegrate(spec, config, ctx) {
28
28
  const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
29
29
  const branch = spec.branch
30
- const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
30
+ const result = {
31
+ blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
32
+ }
31
33
 
32
34
  // The completion edits must be committed first — never rebase a dirty tree.
33
35
  if (worktreeState.dirty) {
34
36
  return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
35
37
  }
36
38
 
37
- // Nothing on the branch that isn't already on base → already landed.
39
+ // Nothing on the branch that isn't already on base → already landed. The
40
+ // after-land commands still apply: the land may have happened on another
41
+ // machine or in an earlier session, leaving them pending in THIS checkout.
38
42
  if (!aheadOfBase) {
39
- return { ...result, noop: true }
43
+ return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath) }
40
44
  }
41
45
 
42
46
  return {
@@ -45,6 +49,7 @@ function planIntegrate(spec, config, ctx) {
45
49
  `git -C ${spec.worktreePath} rebase ${base}`,
46
50
  `git -C ${mainRepoPath} merge --ff-only ${branch}`,
47
51
  ],
52
+ afterLand: afterLandFor(config, mainRepoPath),
48
53
  }
49
54
  }
50
55
 
@@ -65,7 +70,9 @@ function planIntegrate(spec, config, ctx) {
65
70
  function planIntegrateCheckout(spec, config, ctx) {
66
71
  const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
67
72
  const branch = spec.branch
68
- const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
73
+ const result = {
74
+ blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
75
+ }
69
76
 
70
77
  // "Already landed" is answered FIRST, before any refusal. A landed spec needs
71
78
  // no action wherever the checkout happens to be standing — and after a
@@ -73,7 +80,7 @@ function planIntegrateCheckout(spec, config, ctx) {
73
80
  // would refuse the very spec this just finished landing. /spec-complete calls
74
81
  // integrate again on exactly that state.
75
82
  if (!aheadOfBase) {
76
- return { ...result, noop: true }
83
+ return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath) }
77
84
  }
78
85
  if (dirty) {
79
86
  return {
@@ -100,7 +107,20 @@ function planIntegrateCheckout(spec, config, ctx) {
100
107
  `git -C ${checkoutPath} switch ${base}`,
101
108
  `git -C ${checkoutPath} merge --ff-only ${branch}`,
102
109
  ],
110
+ afterLand: afterLandFor(config, checkoutPath),
103
111
  }
104
112
  }
105
113
 
106
- module.exports = { planIntegrate, planIntegrateCheckout }
114
+ // The after-land half of a plan: `config.afterLand`, to run in `cwd` (the
115
+ // primary checkout, standing on base) once the land commands have succeeded.
116
+ // A blocked plan carries the empty form — nothing landed, so nothing follows.
117
+ function afterLandFor(config, cwd) {
118
+ const commands = (config && Array.isArray(config.afterLand)) ? [...config.afterLand] : []
119
+ return { cwd: commands.length ? cwd : null, commands }
120
+ }
121
+
122
+ function noAfterLand() {
123
+ return { cwd: null, commands: [] }
124
+ }
125
+
126
+ module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor }
@@ -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,
package/src/env/review.js CHANGED
@@ -668,7 +668,13 @@ function surfacesFor({ live, tiers, buttons = null }) {
668
668
  // the primary checkout — changing behaviour for everyone who pulls and
669
669
  // leaving that tree dirty. A setting change of that reach must not land
670
670
  // because someone tapped a button labelled only `Allow remote`.
671
- note: t.note || 'writes env.config.json — committed, and shared',
671
+ // `remote` is per spec now (bug-remote-review-toggle): a gitignored
672
+ // sidecar, so the base branch is untouched and no other spec may publish.
673
+ note:
674
+ t.note ||
675
+ (t.tier === 'remote'
676
+ ? 'this spec only — nothing committed'
677
+ : 'writes env.config.json — committed, and shared'),
672
678
  // The same thing from a terminal, for the reader who is nowhere near the
673
679
  // page. Only `remote` has a command of its own: `network` is ON by
674
680
  // default, so turning it off is a rare deliberate act and a slash command
@@ -2391,6 +2397,46 @@ function readReviewUrl(outPath) {
2391
2397
  }
2392
2398
  }
2393
2399
 
2400
+ /**
2401
+ * Whether ONE SPEC may publish, beside its page in the gitignored store.
2402
+ *
2403
+ * WHY PER SPEC. `review.allowRemote` in the committed config permits every
2404
+ * spec in the repo at once, and writing it from a worktree dirtied the base
2405
+ * branch (bug-remote-review-toggle). Publishing sends a diff to a third party,
2406
+ * so what may generate an artifact is decided per worktree; the committed key
2407
+ * stays as the project default beneath it.
2408
+ */
2409
+ function reviewRemotePath(outPath) {
2410
+ return outPath.replace(/\.html$/, '') + '.remote.json'
2411
+ }
2412
+
2413
+ // true / false when the sidecar says so, null for every cannot-tell — absent,
2414
+ // unreadable, a non-boolean — so the committed default stands
2415
+ // (`.claude/rules/negative-checks.md` rule 4: a typo must not permit a publish).
2416
+ function readRemoteAllow(outPath) {
2417
+ try {
2418
+ const v = JSON.parse(fs.readFileSync(reviewRemotePath(outPath), 'utf8')).allowRemote
2419
+ return typeof v === 'boolean' ? v : null
2420
+ } catch {
2421
+ return null
2422
+ }
2423
+ }
2424
+
2425
+ function writeRemoteAllow(outPath, on, at = new Date().toISOString()) {
2426
+ const file = reviewRemotePath(outPath)
2427
+ fs.mkdirSync(path.dirname(file), { recursive: true })
2428
+ fs.writeFileSync(file, JSON.stringify({ allowRemote: on, at }, null, 2) + '\n')
2429
+ return file
2430
+ }
2431
+
2432
+ // The config one spec's render reads: the project's, with this spec's own
2433
+ // remote permission over it. A new object — the caller's config is shared.
2434
+ function withSpecRemote(config, outPath) {
2435
+ const own = readRemoteAllow(outPath)
2436
+ if (own === null) return config
2437
+ return { ...config, review: { ...config.review, allowRemote: own } }
2438
+ }
2439
+
2394
2440
  // Default output path for a spec's page, under the gitignored `.spec-env/`.
2395
2441
  function reviewOutPath(dir, specFolder, out) {
2396
2442
  if (out) return path.resolve(dir, out)
@@ -2512,6 +2558,10 @@ module.exports = {
2512
2558
  reviewUrlPath,
2513
2559
  reviewPublishPath,
2514
2560
  readReviewUrl,
2561
+ reviewRemotePath,
2562
+ readRemoteAllow,
2563
+ writeRemoteAllow,
2564
+ withSpecRemote,
2515
2565
  publishedPageNotice,
2516
2566
  reviewServerNotice,
2517
2567
  fragmentTemplate,
package/src/env/serve.js CHANGED
@@ -46,6 +46,7 @@ const {
46
46
  renderReviewPage,
47
47
  renderReviewBlock,
48
48
  reviewOutPath,
49
+ withSpecRemote,
49
50
  validateNotesBlob,
50
51
  readPending,
51
52
  passState: readPassState,
@@ -595,7 +596,9 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
595
596
  receipt: null,
596
597
  worktreeExists: Boolean(spec.worktreePath && fs.existsSync(spec.worktreePath)),
597
598
  })
598
- const tiers = pageTiers(config)
599
+ // This spec's own remote permission, read per request so a toggle shows on
600
+ // the next refresh without restarting the server.
601
+ const tiers = pageTiers(withSpecRemote(config, out))
599
602
 
600
603
  let data = collectReview({
601
604
  spec,
@@ -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)}` +