@skitterbyte/skitterspec 22.4.0 → 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 +3 -2
- package/assets/commands/spec-remote-review.md +24 -9
- package/assets/core/env.config.md +25 -4
- package/assets/rules/spec-planning.md +6 -2
- package/assets/skills/no-spec/SKILL.md +11 -1
- package/assets/skills/spec/SKILL.md +6 -0
- package/assets/skills/spec-complete/SKILL.md +29 -7
- package/assets/skills/spec-diff/SKILL.md +14 -10
- package/assets/skills/spec-to-main/SKILL.md +17 -2
- package/package.json +1 -1
- package/src/cli.js +156 -85
- package/src/env/config.js +15 -0
- package/src/env/hotfix.js +8 -2
- package/src/env/integrate.js +28 -8
- package/src/env/review.js +51 -1
- package/src/env/serve.js +4 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
**
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
@@ -144,6 +144,23 @@ through to a documented conservative default; see the field notes below.
|
|
|
144
144
|
// redundant). Example: ["pnpm install --frozen-lockfile"].
|
|
145
145
|
"setup": [],
|
|
146
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
|
+
|
|
147
164
|
// Host dev servers `spec-env dev up <spec>` starts on the spec's port block
|
|
148
165
|
// (for apps that run via `pnpm dev` on the host, not inside the Docker stack).
|
|
149
166
|
// An array so UI + API (or more) are supervised independently; [] = none.
|
|
@@ -341,10 +358,14 @@ through to a documented conservative default; see the field notes below.
|
|
|
341
358
|
// page opens on your phone, or loopback only. THIS IS WHAT
|
|
342
359
|
// CHOOSES THE BIND; `reader` no longer does. Default: true,
|
|
343
360
|
// which matches what the engine already did.
|
|
344
|
-
// `allowRemote` — whether PUBLISHING is permitted
|
|
345
|
-
// publishes nothing. Default: FALSE,
|
|
346
|
-
// page is one skitterspec cannot
|
|
347
|
-
// 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.
|
|
348
369
|
//
|
|
349
370
|
// `local` and `network` are two doors into ONE ROOM — the page POSTs to
|
|
350
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
|
-
|
|
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. **
|
|
171
|
-
|
|
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. **
|
|
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
|
-
|
|
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
|
|
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
|
|
424
|
-
`specs/.core/env.config.json` in the **primary checkout**, so it changes
|
|
425
|
-
behaviour for everyone who pulls and leaves that tree dirty —
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
433
|
-
|
|
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
|
+
"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,
|
|
@@ -1732,7 +1735,8 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
1732
1735
|
}
|
|
1733
1736
|
if (cplan.noop) {
|
|
1734
1737
|
process.stdout.write(
|
|
1735
|
-
`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),
|
|
1736
1740
|
)
|
|
1737
1741
|
return
|
|
1738
1742
|
}
|
|
@@ -1740,7 +1744,7 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
1740
1744
|
` base: ${base}`, ` branch: ${cplan.branch}`, '',
|
|
1741
1745
|
' run these (abort the rebase on conflict):']
|
|
1742
1746
|
for (const cmd of cplan.commands) cout.push(` ${cmd}`)
|
|
1743
|
-
process.stdout.write(cout.join('\n') + '\n')
|
|
1747
|
+
process.stdout.write(cout.join('\n') + '\n' + afterLandSection(cplan.afterLand))
|
|
1744
1748
|
return
|
|
1745
1749
|
}
|
|
1746
1750
|
|
|
@@ -1829,7 +1833,8 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
1829
1833
|
}
|
|
1830
1834
|
if (plan.noop) {
|
|
1831
1835
|
process.stdout.write(
|
|
1832
|
-
`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),
|
|
1833
1838
|
)
|
|
1834
1839
|
return
|
|
1835
1840
|
}
|
|
@@ -1843,7 +1848,17 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
1843
1848
|
out.push('')
|
|
1844
1849
|
out.push(' run these (abort the rebase on conflict):')
|
|
1845
1850
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
1846
|
-
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'
|
|
1847
1862
|
}
|
|
1848
1863
|
|
|
1849
1864
|
// Land a hotfix: tag the branch with the patch-bumped base tag (the prod deploy
|
|
@@ -1931,7 +1946,7 @@ function specEnvHotfix(dir, config, positional, flags) {
|
|
|
1931
1946
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
1932
1947
|
out.push('')
|
|
1933
1948
|
out.push(` then push the deploy tag yourself: git push origin ${plan.prodTag}`)
|
|
1934
|
-
process.stdout.write(out.join('\n') + '\n')
|
|
1949
|
+
process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
|
|
1935
1950
|
}
|
|
1936
1951
|
|
|
1937
1952
|
// What the primary checkout has GAINED right now, by content. `dir` is already
|
|
@@ -2526,24 +2541,32 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2526
2541
|
}
|
|
2527
2542
|
|
|
2528
2543
|
/**
|
|
2529
|
-
* `review allow <network|remote> [--off
|
|
2544
|
+
* `review allow <network|remote> [<spec>] [--off | --set <on|off|>] [--project]`
|
|
2545
|
+
* — permit or withdraw a review tier.
|
|
2530
2546
|
*
|
|
2531
|
-
*
|
|
2532
|
-
*
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
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`.
|
|
2535
2551
|
*
|
|
2536
|
-
*
|
|
2537
|
-
*
|
|
2538
|
-
*
|
|
2539
|
-
*
|
|
2540
|
-
*
|
|
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.
|
|
2541
2563
|
*
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
*
|
|
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.
|
|
2545
2568
|
*/
|
|
2546
|
-
function specEnvReviewAllow(dir, config, tier, flags) {
|
|
2569
|
+
function specEnvReviewAllow(dir, config, tier, specArg, flags) {
|
|
2547
2570
|
const TIERS = { network: 'allowNetwork', remote: 'allowRemote' }
|
|
2548
2571
|
const key = TIERS[String(tier || '').trim()]
|
|
2549
2572
|
if (!key) {
|
|
@@ -2556,39 +2579,63 @@ function specEnvReviewAllow(dir, config, tier, flags) {
|
|
|
2556
2579
|
process.exitCode = 1
|
|
2557
2580
|
return
|
|
2558
2581
|
}
|
|
2559
|
-
|
|
2560
|
-
//
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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.
|
|
2569
2629
|
let on = !flags.off
|
|
2570
2630
|
if (flags.set !== undefined) {
|
|
2571
2631
|
const said = String(flags.set).trim().toLowerCase()
|
|
2572
2632
|
if (said === '') {
|
|
2573
|
-
|
|
2574
|
-
// "turn it to the other thing" means the other thing the render showed.
|
|
2575
|
-
const current = Boolean(
|
|
2576
|
-
(() => {
|
|
2577
|
-
try {
|
|
2578
|
-
const now = JSON.parse(fs.readFileSync(path.resolve(dir, 'specs/.core/env.config.json'), 'utf8'))
|
|
2579
|
-
return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
|
|
2580
|
-
} catch {
|
|
2581
|
-
return config.review[key]
|
|
2582
|
-
}
|
|
2583
|
-
})(),
|
|
2584
|
-
)
|
|
2585
|
-
on = !current
|
|
2633
|
+
on = !current()
|
|
2586
2634
|
} else if (said === 'on' || said === 'off') {
|
|
2587
2635
|
on = said === 'on'
|
|
2588
2636
|
} else {
|
|
2589
|
-
// Refused by name, like an unknown tier.
|
|
2590
|
-
//
|
|
2591
|
-
// 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.
|
|
2592
2639
|
process.stdout.write(
|
|
2593
2640
|
`spec-env review allow: ${JSON.stringify(String(flags.set))} is not a state — ` +
|
|
2594
2641
|
'one of on, off, or nothing at all to toggle. Nothing changed.\n',
|
|
@@ -2597,58 +2644,77 @@ function specEnvReviewAllow(dir, config, tier, flags) {
|
|
|
2597
2644
|
return
|
|
2598
2645
|
}
|
|
2599
2646
|
}
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
|
2619
2670
|
}
|
|
2620
|
-
const before = Boolean(
|
|
2621
|
-
parsed.review && parsed.review[key] !== undefined ? parsed.review[key] : config.review[key],
|
|
2622
|
-
)
|
|
2623
|
-
parsed.review = { ...(parsed.review || {}), [key]: on }
|
|
2624
|
-
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
|
|
2625
2671
|
|
|
2626
2672
|
const lines = [
|
|
2627
|
-
`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'}` +
|
|
2628
2674
|
(before === on ? ' (unchanged)' : ''),
|
|
2629
|
-
// THE ABSOLUTE PATH
|
|
2630
|
-
//
|
|
2631
|
-
//
|
|
2632
|
-
|
|
2633
|
-
` wrote: ${file}`,
|
|
2634
|
-
' that is the primary checkout, whichever tree you ran this from, and the file is',
|
|
2635
|
-
' 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}`,
|
|
2636
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
|
+
}
|
|
2637
2688
|
if (key === 'allowNetwork') {
|
|
2638
2689
|
lines.push(
|
|
2639
2690
|
on
|
|
2640
2691
|
? ' the next render binds every interface, so the page opens on your phone.'
|
|
2641
2692
|
: ' the next render binds 127.0.0.1 only, reachable from this machine.',
|
|
2642
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
|
+
)
|
|
2643
2699
|
} else {
|
|
2644
2700
|
lines.push(
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
' turn it back off with: /spec-remote-review'
|
|
2649
|
-
: ' publishing is no longer permitted; any page already published stays up.\n' +
|
|
2650
|
-
' 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',
|
|
2651
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
|
+
}
|
|
2652
2718
|
}
|
|
2653
2719
|
process.stdout.write(lines.join('\n') + '\n')
|
|
2654
2720
|
}
|
|
@@ -2761,6 +2827,9 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2761
2827
|
// CHECKOUT, keyed by the spec's folder name; not one byte of it comes from
|
|
2762
2828
|
// the worktree. `--out` moves the page and its sidecars together.
|
|
2763
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)
|
|
2764
2833
|
|
|
2765
2834
|
// The other half of a confirmation: a pass the operator says is not theirs.
|
|
2766
2835
|
// Left in the store it is reported on every render until they stop reading the
|
|
@@ -5064,6 +5133,7 @@ async function specEnv(rest) {
|
|
|
5064
5133
|
else if (args[i] === '--stop') flags.stop = true
|
|
5065
5134
|
else if (args[i] === '--rotate-token') flags.rotateToken = true
|
|
5066
5135
|
else if (args[i] === '--off') flags.off = true
|
|
5136
|
+
else if (args[i] === '--project') flags.project = true
|
|
5067
5137
|
else if (args[i] === '--status') flags.status = true
|
|
5068
5138
|
else if (args[i] === '--port') flags.port = args[++i]
|
|
5069
5139
|
else if (args[i] === '--host') flags.host = args[++i]
|
|
@@ -5229,7 +5299,7 @@ async function specEnv(rest) {
|
|
|
5229
5299
|
break
|
|
5230
5300
|
}
|
|
5231
5301
|
if (positional[0] === 'allow') {
|
|
5232
|
-
specEnvReviewAllow(dir, config, positional[1], flags)
|
|
5302
|
+
specEnvReviewAllow(dir, config, positional[1], positional[2], flags)
|
|
5233
5303
|
break
|
|
5234
5304
|
}
|
|
5235
5305
|
if (positional[0] === 'skip') {
|
|
@@ -5249,7 +5319,8 @@ async function specEnv(rest) {
|
|
|
5249
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` +
|
|
5250
5320
|
' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
|
|
5251
5321
|
' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
|
|
5252
|
-
' review allow <network|remote> [--off] permit a review tier
|
|
5322
|
+
' review allow <network|remote> [<spec>] [--off] [--project] permit a review tier\n' +
|
|
5323
|
+
' (network: env.config.json; remote: this spec only)\n' +
|
|
5253
5324
|
' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
|
|
5254
5325
|
' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
|
|
5255
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 }
|
package/src/env/integrate.js
CHANGED
|
@@ -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 (
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
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 }
|
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
|
-
|
|
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
|
-
|
|
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,
|