@skitterbyte/skitterspec-linear 14.0.0 → 15.0.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/MIGRATION.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Migration guide
2
2
 
3
+ ## `@skitterbyte/skitterspec` v20 → v21 (the gate installs, and asking implies waiting)
4
+
5
+ If you upgraded to v20 and the commit gate never once fired, this is why. Two
6
+ independent bugs, either of which alone made it a no-op.
7
+
8
+ ### Breaking change
9
+
10
+ **The hook ships as `.claude/hooks/review-gate.cjs`**, renamed from
11
+ `review-gate.js`. The script is CommonJS and it is copied *into your* project,
12
+ where your `package.json` decides how node parses a `.js` — so in any
13
+ `"type": "module"` project it died on its own first `require`, printing a stack
14
+ trace on **every Bash tool call**. `.cjs` settles the parse mode at the file,
15
+ which is the only place independent of the one file skitterspec does not
16
+ control. An ESM rewrite would have inverted the same problem onto CommonJS
17
+ projects, which are still the default for anything with no `"type"` set.
18
+
19
+ The upgrade migrates you: your existing `PreToolUse` entry has its path
20
+ **rewritten in place** — so a wrapper, a flag or a different interpreter you
21
+ added all survive — and the retired `review-gate.js` is deleted. If you edited
22
+ that file yourself it is **kept**, with a warning, and left for you to remove.
23
+
24
+ ### Bug fix
25
+
26
+ **`skitterspec update` now registers the hook.** v20's notes said `init` and
27
+ `update` both did; only `init` did. `update` copied the script, reported
28
+ `created: .claude/hooks/review-gate.js`, and wired nothing — so every project
29
+ that upgraded into v20 got a hook file and no hook, with nothing saying so.
30
+
31
+ ### What to do
32
+
33
+ 1. **Upgrade** — `npx @skitterbyte/skitterspec update`.
34
+ 2. **Commit `.claude/settings.json` and `.claude/hooks/review-gate.cjs`**, and
35
+ the deletion of `.claude/hooks/review-gate.js`. A hook only a fraction of the
36
+ team has is a gate that holds for a fraction of the team.
37
+ 3. **Check it is actually on** — the update reports
38
+ `updated: .claude/settings.json (review-gate hook)` the first time, and
39
+ `unchanged` afterwards. If it says neither, your settings file could not be
40
+ parsed; it was left untouched and the hook is not registered.
41
+
42
+ ### Behaviour change — a bug fix and a hotfix now owe a verdict
43
+
44
+ **`/spec-bug` and `/spec-hotfix` now arm the review gate**, as `/spec-next`
45
+ already did. Both render the page at the end of red→green work, and both now
46
+ wait for your verdict instead of finishing — so in that spec's worktree a
47
+ `git commit` is **refused** until one of two things happens:
48
+
49
+ - you send a verdict from the page (or `/spec-reviewed` picks up one already
50
+ waiting), or
51
+ - you run `skitterspec spec-env review skip "<reason>"`, which records the
52
+ decision to move on.
53
+
54
+ The gate is still only armed by work that **finished**, it still fails open on
55
+ every cannot-tell, and `review.required: false` in `specs/.core/env.config.json`
56
+ still turns it off for a project.
57
+
58
+ **Why.** Those two skills used to render the page, ask *"want a written review
59
+ before you commit?"* and finish with nothing watching. A verdict pressed on that
60
+ page sat in the holding area until someone typed `/spec-reviewed` — which they
61
+ had no reason to do, because the run had said the page was *ready* rather than
62
+ that it was *waiting*. Two verdicts were stranded that way on one spec, and the
63
+ second existed only because the first appeared to do nothing.
64
+
65
+ ### New, and not breaking — the `Continue` verdict
66
+
67
+ **A page rendered part-way through a run offers `Continue`** — *I have read it,
68
+ carry on* — instead of the committing buttons, via
69
+ `spec-env review <spec> --buttons midrun`. The default is unchanged, so a caller
70
+ that passes nothing renders exactly the page it rendered before. `Continue` can
71
+ never clear an armed gate: that still takes a committing verdict or a recorded
72
+ skip.
73
+
74
+ Nothing else changes: `review.required` still defaults to `true`, and the engine
75
+ and `/spec-next` held the gate throughout regardless of the hook.
76
+
77
+ ## `@skitterbyte/skitterspec-linear` v14 → v15 (the gate installs, and asking implies waiting)
78
+
79
+ The same change as `@skitterbyte/skitterspec` v20 → v21 above — this
80
+ distribution composes the same lifecycle skills. Read that entry; nothing here
81
+ is Linear-specific.
82
+
3
83
  ## `@skitterbyte/skitterspec` v19 → v20 (a phase owes a verdict)
4
84
 
5
85
  ### Breaking change
@@ -41,8 +121,10 @@ cannot-tell lets the commit through.
41
121
  ### Breaking change
42
122
 
43
123
  **`.claude/settings.json` is now written by the installer.** `skitterspec init`
44
- and `skitterspec update` copy `.claude/hooks/review-gate.js` and register it as
45
- a `PreToolUse` hook in your project's **committed** settings file. That is a
124
+ and `skitterspec update` copy the hook script and register it as a `PreToolUse`
125
+ hook in your project's **committed** settings file. (In v20 `update` copied
126
+ without registering, and the script was named `review-gate.js` — both fixed in
127
+ v21; read that entry above if you are landing on the current release.) That is a
46
128
  tracked file in most repos, so expect it in `git status` after upgrading — and
47
129
  commit it, because a hook only a fraction of the team has is a gate that holds
48
130
  for a fraction of the team.
@@ -76,8 +158,8 @@ as before.
76
158
  ### What to do
77
159
 
78
160
  1. **Upgrade** — `npx @skitterbyte/skitterspec update`.
79
- 2. **Commit `.claude/settings.json` and `.claude/hooks/review-gate.js`.** Both
80
- are new in your working tree after the update.
161
+ 2. **Commit `.claude/settings.json` and the hook script.** Both are new in your
162
+ working tree after the update.
81
163
  3. **Nothing else to configure.** `review.required` defaults to `true` and
82
164
  `review.commitWith` defaults to `/commit`; neither needs adding unless you
83
165
  are changing it.
@@ -74,8 +74,9 @@ no live `env.config.json` was found.
74
74
  // `spec-env up`, right after `git worktree add` and BEFORE `setup` runs — so a
75
75
  // fresh linked worktree (which starts with none of the repo's gitignored files)
76
76
  // has the .env / local secret overrides / local config that setup steps and
77
- // git hooks depend on. Without this a step like `prisma generate` hard-fails in
78
- // the new worktree because .env (its datasource URL) isn't there.
77
+ // git hooks depend on. Without this any setup step that reads .env a schema
78
+ // or client generator, a codegen pass hard-fails in the new worktree
79
+ // because the file it expects isn't there.
79
80
  // mode "symlink" (default) points the worktree file at the main file, so it
80
81
  // stays in sync; "copy" makes an independent copy.
81
82
  // files repo-relative paths to seed. A source absent in main is a printed
@@ -386,7 +386,13 @@ tr.note-row td {
386
386
  .verdict-title { font-size: .8rem; color: var(--muted); }
387
387
  .verdict-count { font-size: .8rem; color: var(--muted); }
388
388
  .v-commit { color: var(--good-fg); background: var(--good-bg); }
389
+ /* Continue borrows the same affirmative pair: it is the mid-run reader saying
390
+ carry on, and it never appears beside a commit button to be confused with. */
391
+ .v-continue { color: var(--good-fg); background: var(--good-bg); }
389
392
  .v-changes { color: var(--flag-fg); background: var(--flag-bg); }
393
+ /* A button the render did not ask for is GONE, not dimmed — a disabled control
394
+ invites a reader to work out how to enable it, and there is nothing to do. */
395
+ .verdict button[hidden] { display: none; }
390
396
  /* A blocked control is dashed as well as dimmed, so the block survives a
391
397
  greyscale screen — and the reason rides in the label, never in a tooltip
392
398
  nobody hovers on a phone. */
@@ -570,6 +576,12 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
570
576
  <span class="verdict-title">Your verdict</span>
571
577
  <button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
572
578
  <button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit &amp; Continue</button>
579
+ <!-- THE MID-RUN VERDICT, hidden unless the render asked for it. "Commit"
580
+ is the wrong verb for unfinished work, so a page rendered part-way
581
+ through a run offers this instead of the committing pair: I have
582
+ read it, carry on. It is not the removed `none` verdict — that
583
+ recorded itself and did nothing, while this one resumes the run. -->
584
+ <button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
573
585
  <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
574
586
  <button id="verdict-discuss" type="button">… Discuss first</button>
575
587
  <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
@@ -1216,6 +1228,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1216
1228
  var verdictBtns = {
1217
1229
  commit: document.getElementById('verdict-commit'),
1218
1230
  'commit-continue': document.getElementById('verdict-commit-continue'),
1231
+ continue: document.getElementById('verdict-continue'),
1219
1232
  changes: document.getElementById('verdict-changes'),
1220
1233
  discuss: document.getElementById('verdict-discuss'),
1221
1234
  }
@@ -1223,12 +1236,34 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1223
1236
  // here, and the shared block below follows for free — the same reason the
1224
1237
  // engine keeps a `COMMITTING` list rather than a second condition.
1225
1238
  var COMMITTERS = ['commit', 'commit-continue']
1226
- // ALL FOUR, because this is now the one place a verdict gets a reader-facing
1239
+
1240
+ /*
1241
+ * WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
1242
+ * from the gate. Deriving it — mid-run iff nothing is armed — would take the
1243
+ * committing buttons away from every page in a project running
1244
+ * `review.required: false`, which never arms and whose readers must still be
1245
+ * able to commit from the page. The engine's `BUTTON_SETS` is the same
1246
+ * decision on the other side of the wire.
1247
+ *
1248
+ * ABSENT IS THE COMMITTING SET. A page rendered before any of this existed,
1249
+ * and a caller that did not ask, both land here — so nothing about their page
1250
+ * changes.
1251
+ */
1252
+ var MIDRUN = data.buttons === 'midrun'
1253
+ var OFFERED = MIDRUN ? ['continue', 'changes', 'discuss'] : ['commit', 'commit-continue', 'changes', 'discuss']
1254
+ Object.keys(verdictBtns).forEach(function (key) {
1255
+ verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
1256
+ })
1257
+
1258
+ // EVERY VERDICT, because this is the one place a verdict gets a reader-facing
1227
1259
  // name: `refresh` relabels only the committing pair, but the decided panel
1228
- // has to name whichever verdict was actually sent.
1260
+ // has to name whichever verdict was actually sent — including one this page
1261
+ // never offered, since a decision restored from storage outlives the render
1262
+ // that made it.
1229
1263
  var LABELS = {
1230
1264
  commit: '✓ Commit',
1231
1265
  'commit-continue': '✓ Commit & Continue',
1266
+ continue: '→ Continue',
1232
1267
  changes: '↺ Request changes',
1233
1268
  discuss: '… Discuss first',
1234
1269
  }
@@ -1237,6 +1272,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1237
1272
  // Says what it will NOT do: a reader must not press this expecting the
1238
1273
  // spec to be finished and landed.
1239
1274
  'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1275
+ // Says what it will NOT do, for the same reason: nothing is committed by
1276
+ // this, so the work it resumes is still uncommitted afterwards.
1277
+ continue: 'You have read it — carry on with the run; nothing is committed',
1240
1278
  }
1241
1279
 
1242
1280
  var verdictCount = document.getElementById('verdict-count')
@@ -1286,7 +1324,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1286
1324
  var open = openNotes()
1287
1325
  verdictCount.textContent = n
1288
1326
  ? n + (n === 1 ? ' mark to send' : ' marks to send')
1289
- : 'Nothing marked a clean read still commits'
1327
+ // Names the action this page actually offers. A mid-run page saying "a
1328
+ // clean read still commits" would promise the one thing it cannot do.
1329
+ : MIDRUN ? 'Nothing marked — a clean read still carries on' : 'Nothing marked — a clean read still commits'
1290
1330
  // ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
1291
1331
  // reads is how a page ends up with one disabled and the other not — which
1292
1332
  // would be a way around the single refusal this page makes.
@@ -1315,8 +1355,15 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1315
1355
  : TITLES[key]
1316
1356
  })
1317
1357
  // Said rather than left unset: the committing pair is the ONLY thing this
1318
- // page ever blocks, and the other two are always available on purpose — a
1319
- // note you want acted on, and a question, are never the wrong thing to send.
1358
+ // page ever blocks, and the others are always available on purpose — a note
1359
+ // you want acted on, and a question, are never the wrong thing to send.
1360
+ //
1361
+ // CONTINUE IS NOT BLOCKED, and that mirrors the engine rather than softening
1362
+ // it: `judgeVerdict` refuses a COMMITTING verdict against an open note, and
1363
+ // `continue` is deliberately not one. A disabled button here would be a
1364
+ // second opinion about a refusal the engine does not make, which is the one
1365
+ // thing this bar must never be.
1366
+ verdictBtns.continue.disabled = false
1320
1367
  verdictBtns.changes.disabled = false
1321
1368
  verdictBtns.discuss.disabled = false
1322
1369
  save()
@@ -1400,6 +1447,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1400
1447
  approve: 'committed',
1401
1448
  commit: 'committed',
1402
1449
  'commit-continue': 'committed, then carried on',
1450
+ continue: 'read it and carried on',
1403
1451
  changes: 'changes requested',
1404
1452
  discuss: 'discussed',
1405
1453
  skip: 'moved on without a verdict',
@@ -1716,9 +1764,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1716
1764
 
1717
1765
  Object.keys(verdictBtns).forEach(function (verdict) {
1718
1766
  verdictBtns[verdict].addEventListener('click', function () {
1719
- // A disabled button does not fire in a browser; the guard is for every
1720
- // other caller, and it keeps the block a fact rather than a style.
1721
- if (verdictBtns[verdict].disabled) return
1767
+ // A disabled or hidden button does not fire in a browser; the guard is
1768
+ // for every other caller, and it keeps both the block and the button set
1769
+ // facts rather than styles — a verdict this render never offered must not
1770
+ // be reachable by dispatching at it.
1771
+ if (verdictBtns[verdict].disabled || verdictBtns[verdict].hidden) return
1722
1772
  send(verdict)
1723
1773
  })
1724
1774
  })
@@ -244,10 +244,10 @@ things clear it — a **committing verdict**, or
244
244
  `git commit` refuses in that worktree. `skitterspec spec-env review gate
245
245
  [--check] [--json]` is what both ask.
246
246
 
247
- **The hook is the half that cannot be talked past.** `skitterspec init` installs
248
- `.claude/hooks/review-gate.js` and registers it in the project's committed
249
- `.claude/settings.json`, so a `git commit` is refused by the harness rather than
250
- by prose — which is what covers a bare `git commit`, a chained command, and
247
+ **The hook is the half that cannot be talked past.** `skitterspec init` and
248
+ `skitterspec update` install `.claude/hooks/review-gate.cjs` and register it in
249
+ the project's committed `.claude/settings.json`, so a `git commit` is refused by
250
+ the harness rather than by prose — which is what covers a bare `git commit`, a chained command, and
251
251
  skittership's own `/commit` without skitterspec ever editing it. It decides
252
252
  nothing itself: it hands the command line to
253
253
  `spec-env review gate --check --for-command` and turns one exit status into an
@@ -301,7 +301,7 @@ its verdicts and its fields.
301
301
  | **Branch** | `spec/feat-orders` · 3 commits, clean |
302
302
  | **Built** | POST /orders handler, orders schema |
303
303
  | **Tests** | 128 passed · npm test |
304
- | **Review** | 7 files, +212 −18 · [open the page](file:///…) — want a written review before you commit? |
304
+ | **Review** | 7 files, +212 −18 · [open the page](file:///…) |
305
305
  | **Follow-ups** | none |
306
306
  | **Next** | `/spec-next` → phase 3 (Auth) |
307
307
 
@@ -36,7 +36,7 @@ A verdict sentence, a blank line, then a two-column table:
36
36
  | **Branch** | `spec/feat-foo` · 3 commits, clean |
37
37
  | **Built** | POST /orders handler, orders schema |
38
38
  | **Tests** | 128 passed · npm test |
39
- | **Review** | 7 files, +212 −18 · [open the page](file:///…) — want a written review before you commit? |
39
+ | **Review** | 7 files, +212 −18 · [open the page](file:///…) |
40
40
  | **Follow-ups** | none |
41
41
  | **Next** | `/spec-next` → phase 3 (Auth) |
42
42
 
@@ -97,15 +97,54 @@ A rule, a heading naming the state and the size, the link in bold, one line
97
97
  saying the run is stopped, and the two exits. Nothing else, and never a
98
98
  paragraph explaining it.
99
99
 
100
- **Only promise a wait the transport can deliver.** A page the engine SERVES
101
- hands its pass to the local store, which a file-watch sees that is the one
102
- case where *"I'm holding here"* is true. A **published** page writes to the
103
- artifact's own store, and nothing pushes from there into the conversation, so a
104
- verdict pressed on it is invisible until someone asks for it. Its banner says
105
- so instead:
100
+ **Only promise a wait the transport can deliver.** The wait itself is real in
101
+ every case; what varies is what carries it back, not whether the run is
102
+ stopped. Three transports, three true sentences:
103
+
104
+ - **A page the engine SERVES** hands its pass to the local store, which a
105
+ file-watch sees. The run is woken by the press:
106
+ *"I'm holding here until you send a verdict."*
107
+ - **A `file://` page** has no server to POST to, so the pass is copied and
108
+ pasted — the reader's next message is what carries it. The run is stopped just
109
+ the same, and says so:
110
+ *"I'm holding here — paste the pass when you have it."*
111
+ This is the same wait, carried by the conversation instead of a watch. It is
112
+ not a lesser one, and it is not an excuse to ask without waiting.
113
+ - **A PUBLISHED page** writes to the artifact's own store, and nothing pushes
114
+ from there into the conversation. That is the one case where a verdict is
115
+ genuinely invisible until someone asks for it, so the banner says that instead
116
+ of claiming a watch:
106
117
 
107
118
  **[Open the page](https://…)** · press a verdict, then type `/spec-reviewed` — I cannot see it until you do.
108
119
 
120
+ **A harness with no file-watch does not get an exemption.** It once read
121
+ "change nothing" — keep the row, keep the question, do not wait — and that
122
+ escape hatch is what let a whole class of runs go on asking questions nobody was
123
+ listening for. Where a watch is unavailable the wait is the turn ending, which
124
+ every harness can do.
125
+
126
+ **The `Continue` ending, and why the banner's exits differ mid-run.** A page
127
+ rendered part-way through a run offers `Continue` — *I have read it, carry on* —
128
+ in place of the committing verdicts, because "commit" is the wrong verb for
129
+ unfinished work. Its banner names the same state and different exits:
130
+
131
+ **[Open the page](http://…)** · I'm holding here until you send a verdict.
132
+
133
+ `Continue` carries on · `Request changes` works them now
134
+
135
+ **It is not the `none` verdict that was removed.** `none` recorded itself and
136
+ did nothing, which is the record-and-do-nothing ending this contract exists
137
+ against. `Continue` **resumes the run** — it names an action, which is the bar
138
+ every verdict has to clear. What it does not do is commit, and it can never
139
+ clear the gate a finished phase armed: that gate is discharged by a committing
140
+ verdict or a recorded skip, and by nothing else.
141
+
142
+ **So waiting and arming are separate**, and only one of them is about this
143
+ block. *Waiting* is what any offer does, and it is what this section governs.
144
+ *Arming* asserts an obligation that outlives the turn, and belongs only to work
145
+ that is finished. A mid-run render waits without arming — walking away from it
146
+ costs nothing, which is exactly why `Continue` is safe to offer there.
147
+
109
148
  **Exactly one link, never two.** The banner offers the page the reader can
110
149
  actually use, and the engine has already decided which that is — a served URL
111
150
  when it could serve, a published one only when it could not. Handing over both
@@ -124,11 +163,34 @@ stopped until they answer. A row cannot carry that, because a row is scanned at
124
163
  the same weight as every other row. So the state gets the loudest shape on
125
164
  screen, at the end, where reading finishes.
126
165
 
127
- **Where the run is not waiting, it stays a `Review` row.** A render nobody is
128
- being held for a mid-phase `/spec-diff`, a page produced alongside other work
129
- is a convenience, and giving it a banner teaches the reader to scroll past
130
- banners. The shape marks the difference between *here is a page* and *nothing
131
- proceeds until you answer*.
166
+ **ASKING IMPLIES WAITING one rule, and the two shapes follow from it.** Any
167
+ render that asks the reader for a verdict ends the turn watching for one. A run
168
+ that does not intend to wait does not ask: its `Review` row keeps the counts and
169
+ the link and **loses the question**.
170
+
171
+ So there are two shapes and no third:
172
+
173
+ - **Asking** → the banner above, and the run waits.
174
+ - **Not asking** → the `Review` row: counts and link, no question.
175
+
176
+ **What this guards against is a reader taught that the button is decorative.**
177
+ `/spec-bug` and `/spec-hotfix` used to render the page, emit a row
178
+ asking *"want a written review before you commit?"*, and finish — with nothing
179
+ watching. A verdict pressed on that page landed in the holding area and stayed
180
+ there, because the run had told the reader the page was **ready** rather than
181
+ that it was **waiting**. It happened twice on one spec, and the second press
182
+ existed only because the first appeared to do nothing. An offer that cannot be
183
+ answered costs more than never having offered.
184
+
185
+ **Rejected: keeping the question and adding a caveat** — *"press a verdict, but
186
+ I will not see it until you ask"*. That is exactly the shape that stranded those
187
+ two passes, and a truthful caveat does not make an unanswerable question worth
188
+ asking. Either watch for the answer, or do not ask.
189
+
190
+ **A render nobody is being held for is still worth reporting** — a mid-phase
191
+ `/spec-diff`, a page produced alongside other work. It gets the row, because
192
+ giving it a banner teaches the reader to scroll past banners. The shape marks
193
+ the difference between *here is a page* and *nothing proceeds until you answer*.
132
194
 
133
195
  **Never fence a message to the reader.** A fenced block is for a command to
134
196
  run, code, or engine output quoted verbatim — things the reader copies or
@@ -185,7 +247,7 @@ behind it — say nothing rather than reporting that there was nothing to report
185
247
  | `Landed` | A fast-forward, a tag, a cherry-pick. |
186
248
  | `Worktree` | A worktree provisioned, entered, or torn down. |
187
249
  | `Untouched` | Uncommitted work the run deliberately left alone — whose, and how much. |
188
- | `Review` | The rendered diff page: files, `+`/`−`, the link — and the offer of a written review, in the same row. **Omitted entirely when the run is waiting on a verdict**: the banner after the block carries it instead, and a row saying the same thing beside it splits the reader's attention across two places. |
250
+ | `Review` | The rendered diff page: files, `+`/`−`, the link. **No question** a row cannot be waited on, so a question in one is unanswerable by construction (see *asking implies waiting*). **Omitted entirely when the run is waiting on a verdict**: the banner after the block carries it instead, and a row saying the same thing beside it splits the reader's attention across two places. |
189
251
  | `Follow-ups` | **Always.** `none`, or one line each. |
190
252
  | `Next` | **Last.** The single next action for this work — runnable from the state the run leaves behind. |
191
253
 
@@ -204,11 +266,17 @@ silent, because "I provisioned, and your four files are still sitting there" is
204
266
  a different sentence from "I provisioned".
205
267
 
206
268
  **`Review` is one row, not two** — and where the run is waiting, it is no rows
207
- at all. The page and the offer to read it are the same subject, and splitting
208
- them made the reader resolve a distinction before acting on either. A waiting
209
- run promotes the whole subject into the banner; keeping the row as well would
269
+ at all. The page and what to do with it are the same subject, and splitting them
270
+ made the reader resolve a distinction before acting on either. A waiting run
271
+ promotes the whole subject into the banner; keeping the row as well would
210
272
  recreate that split with the two halves further apart than ever.
211
273
 
274
+ **The row carries no question, and that is the rule above applied here.** It
275
+ once ended *"want a written review before you commit?"* — a question addressed
276
+ to a reader the run was not waiting for. Putting it in a findable row fixed
277
+ where it was; it did not fix that nothing was listening. A run that wants that
278
+ question answered waits for it, and waiting means the banner.
279
+
212
280
  ## The four verdicts
213
281
 
214
282
  | State | Means |
@@ -451,7 +451,7 @@ sub-issue jump from Backlog straight to Done, with nothing visible in between.
451
451
  - **Say what happened** in the skill's report: mirror updated, skipped as
452
452
  unlinked, or failed with the reason.
453
453
 
454
- ## 5b. Render the page then offer the review, never write it
454
+ ## 5b. Render the page, arm the gate, then wait for the verdict
455
455
 
456
456
  **Only when the project has per-spec isolation** (`specs/.core/env.config.json`
457
457
  present). Without it there is no worktree to read and this step does not exist —
@@ -467,28 +467,37 @@ skitterspec spec-env review <spec>
467
467
  **This is free.** The engine reads git and splices the patches into a template;
468
468
  the diff never passes through you, so a 266KB patch costs nothing.
469
469
 
470
- **Then offer `/spec-diff`. Do not run it.** The written review is the part that
471
- costs — roughly **700 output tokens**, because writing it means reading the diff
472
- — and that spend is the operator's call, not a default.
470
+ **Then arm the gate**, so the fix now owes a verdict:
473
471
 
474
- **The offer is the `Review` row of step 6's block** — the counts, the page link
475
- and a question, in one row:
476
-
477
- | **Review** | <N> files, +<a> −<d> · [open the page](<the `open:` URL>) — want a written review before you commit? |
478
-
479
- **It ends in a question, addressed to someone.** It was once a fenced block of
480
- engine output, and it fired on every phase and was never once taken: two quoted
481
- lines under the test counts, addressed to nobody, with the report then closing
482
- on *"commit this first"* — the last instruction the reader got was to move on,
483
- so they did. A row in a labelled table is findable; a question in it is
484
- answerable. Both halves are load-bearing.
485
-
486
- **Never bury it and never split it.** It sits above `Follow-ups` and `Next`, and
487
- the page and the question stay in the same row: two adjacent rows about one page
488
- make the reader resolve a distinction before acting on either. A later edit that
489
- moves it out of the block, or separates the link from the question, undoes this
490
- and should be read as a regression rather than tidying.
472
+ ```
473
+ skitterspec spec-env review arm <spec>
474
+ ```
491
475
 
476
+ **Then wait**, and `/spec-next` §5 owns the sequence — follow it there rather
477
+ than reading a second copy here: note the moment you start waiting, watch the
478
+ pending store, end your turn, and let `--claim-since` pick the one pass that
479
+ arrived inside the window. The banner it describes is what this skill emits in
480
+ place of a `Review` row, and the routing on the verdict is `/spec-diff` §2 and
481
+ §4, as it is everywhere.
482
+
483
+ **Why this skill arms as well as waits.** Waiting is what any offer does;
484
+ **arming** asserts an obligation that outlives the turn, and belongs only to
485
+ work that is finished. A bug fix is a completed unit — red→green, suite
486
+ passing — so it qualifies, and a wait with nothing owed behind it is a
487
+ suggestion rather than a gate. A mid-run render waits without arming, and
488
+ walking away from that costs nothing.
489
+
490
+ **This is the gap this step existed inside.** It used to render the page, emit a
491
+ row asking *"want a written review before you commit?"*, and finish — with
492
+ nothing watching. A verdict pressed on that page landed in the holding area and
493
+ stayed there, because the run had said the page was **ready** rather than that
494
+ it was **waiting**. Two were pressed on one spec and both were stranded; the
495
+ second existed only because the first appeared to do nothing.
496
+
497
+ **It is user-visible, and that is deliberate.** Once armed, a `git commit` in
498
+ this worktree is refused until a verdict is sent or
499
+ `skitterspec spec-env review skip "<reason>"` records the decision to move on.
500
+ The exit is always one command, and one of them is *"I am moving on"*.
492
501
 
493
502
  Relay the **`open:`** line rather than the bare path: a path is not clickable in
494
503
  any terminal, and a page nobody can open is a page nobody reads.
@@ -550,9 +559,15 @@ the shape; this section carries only what is specific here.
550
559
  **Fields:** `Tracker` · `Branch` · `Spec` · `Cause` · `Built` · `Tests` ·
551
560
  `Review` · `Follow-ups` · `Next`
552
561
 
562
+ **`Review` is emitted only where step 5b did not run** — a project with no
563
+ isolation, or a render that failed. Where the run is waiting, the banner carries
564
+ the whole subject and the row is dropped, per `.claude/rules/spec-reports.md`.
565
+
553
566
  `Cause` is the root cause in one clause and `Built` is the fix — the
554
567
  failing→passing test belongs in `Tests`, named, so the evidence is a test name
555
568
  rather than an adjective.
556
569
 
557
- Step 5b's offer is the `Review` row, not a paragraph after the block — the
558
- counts, the link and the question in one row. Nothing follows the block.
570
+ Step 5b ends in the **banner**, not a paragraph and not a row — the run is
571
+ waiting on a verdict, and the banner is the shape that says so. Nothing follows
572
+ it. Where the run is not waiting it asks nothing at all: a `Review` row carries
573
+ the counts and the link, and no question (*asking implies waiting*).
@@ -401,6 +401,31 @@ Add `--json` to get the file list, totals and the page path back as data. The
401
401
  page is written to `.spec-env/reviews/<spec>.html`, which is gitignored — it
402
402
  leaves no trace in the branch under review.
403
403
 
404
+ **Declare the button set when the work is unfinished.** A page rendered part-way
405
+ through a run takes `--buttons midrun`, and offers `Continue` — *I have read it,
406
+ carry on* — in place of `Commit` and `Commit & Continue`:
407
+
408
+ ```
409
+ skitterspec spec-env review <spec> --buttons midrun
410
+ ```
411
+
412
+ **It is a statement about the work, never a reading of the gate.** Ask whether
413
+ the thing you just rendered is *finished*: a phase that ended, a bug fix that is
414
+ green, a spec about to land — committing set, which is the default, so pass
415
+ nothing. Half a phase, a hand edit, a colleague's branch mid-flight, anything
416
+ the operator asked to look at while it is still moving — `midrun`.
417
+
418
+ **Do not derive it from whether the gate is armed.** That is tidier and wrong: a
419
+ project running `review.required: false` never arms at all, so every one of its
420
+ pages would lose the committing buttons and its reader could never commit from
421
+ the page. The caller knows what it rendered; the gate only knows whether the
422
+ project opted into gating.
423
+
424
+ `Continue` is not the removed `none` verdict. `none` recorded itself and did
425
+ nothing; `Continue` **resumes the run**, so it still names an action. What it
426
+ cannot do is commit, or clear a gate a finished phase armed — that takes a
427
+ committing verdict or a recorded skip.
428
+
404
429
  **On `--page-only`, stop here** and report the path.
405
430
 
406
431
  ## 4a. Read the `reader:` line — never sniff for it yourself
@@ -439,7 +464,7 @@ operator settle where they are reading, and an explicit value is believed withou
439
464
  sniffing. `review.serveOnRemote` (default `true`) settles whether the engine may
440
465
  act on it.
441
466
 
442
- ## 4b. Wait for the verdict, where the harness can watch a file
467
+ ## 4b. Wait for the verdict because asking for one means waiting for it
443
468
 
444
469
  A served page can hand its pass back the moment it is pressed. Without a wait,
445
470
  that pass lands in the holding area and stops — nothing happens until someone
@@ -448,10 +473,28 @@ acts joined only by the operator remembering.
448
473
  **This is the step that joins them**, and it is this skill's, so `/spec-next` can point here rather than
449
474
  keeping a second copy.
450
475
 
451
- **Wait when the page was served and the reader is going to read it now.** That
452
- is the phase-end case, and an operator who says they will look later. Do not
453
- wait on a bare `--page-only`, on a render nobody asked to be woken about, or
454
- when the pass has already arrived there is nothing to wait for.
476
+ **The rule is `.claude/rules/spec-reports.md`'s: asking implies waiting.** If
477
+ this render asks the reader for a verdict, it waits for one. If it is not going
478
+ to wait, it does not ask the report names the page and its size and stops
479
+ there. There is no third option, and in particular there is no *ask now, notice
480
+ later*: that is the shape that stranded two passes on one spec, where the run
481
+ said the page was **ready** rather than that it was **waiting**.
482
+
483
+ **So the question is not "can I watch a file".** It is "am I asking?" — and the
484
+ transport only decides what carries the answer back:
485
+
486
+ - **A served page** posts to the local store; a file-watch sees it, and the
487
+ steps below are that watch.
488
+ - **A `file://` page** has nothing to post to, so the reader pastes the pass and
489
+ their next message carries it. Say you are holding and **end the turn** — that
490
+ is the same wait, carried by the conversation. It is not a lesser one.
491
+ - **A published page** writes to the artifact's own store, which nothing reaches
492
+ from here. That is the one case where the honest sentence is *press a verdict,
493
+ then type `/spec-reviewed`* — see the published-page paragraph below.
494
+
495
+ **Do not ask, and therefore do not wait, on a render nobody is waiting behind** —
496
+ a bare `--page-only`, a page produced alongside other work, a pass that has
497
+ already arrived. Those get the `Review` row, no question, and nothing is owed.
455
498
 
456
499
  1. **Note the moment**, as an ISO timestamp, before you start. That instant is
457
500
  the entire scope of what may be claimed without a person naming it.
@@ -492,10 +535,16 @@ Where the page is published, say plainly that `/spec-reviewed` is what picks it
492
535
  up.
493
536
 
494
537
  **`/spec-reviewed` is not replaced by this.** It stays the way in for a pass
495
- that arrived when nobody was waiting, for the two-passes case, for every
496
- published page, and for every harness with no file-watch at all. It is user-only, and that is still the
538
+ that arrived when nobody was waiting, for the two-passes case, and for every
539
+ published page. It is user-only, and that is still the
497
540
  enforcement that makes a named claim a person's decision.
498
541
 
542
+ **A harness with no file-watch is no longer on that list**, and that is the
543
+ change. It used to be — the row and `/spec-reviewed` were "the whole story",
544
+ which read as permission to ask without waiting. Now the turn ending is the
545
+ wait, and every harness can end a turn; `/spec-reviewed` remains available
546
+ there, as it is everywhere, for a pass nobody was holding for.
547
+
499
548
  ## 5. Offer the written review — say what it costs first
500
549
 
501
550
  The page is free. The **written review is not**, and it costs in two separate
@@ -473,7 +473,7 @@ sub-issue jump from Backlog straight to Done, with nothing visible in between.
473
473
  - **Say what happened** in the skill's report: mirror updated, skipped as
474
474
  unlinked, or failed with the reason.
475
475
 
476
- ## 6b. Render the page then offer the review, never write it
476
+ ## 6b. Render the page, arm the gate, then wait for the verdict
477
477
 
478
478
  **Only when the project has per-spec isolation** (`specs/.core/env.config.json`
479
479
  present). Without it there is no worktree to read and this step does not exist —
@@ -489,28 +489,33 @@ skitterspec spec-env review <spec>
489
489
  **This is free.** The engine reads git and splices the patches into a template;
490
490
  the diff never passes through you, so a 266KB patch costs nothing.
491
491
 
492
- **Then offer `/spec-diff`. Do not run it.** The written review is the part that
493
- costs — roughly **700 output tokens**, because writing it means reading the diff
494
- — and that spend is the operator's call, not a default.
492
+ **Then arm the gate**, so the fix now owes a verdict:
495
493
 
496
- **The offer is the `Review` row of step 6's block** — the counts, the page link
497
- and a question, in one row:
494
+ ```
495
+ skitterspec spec-env review arm <spec>
496
+ ```
498
497
 
499
- | **Review** | <N> files, +<a> −<d> · [open the page](<the `open:` URL>) want a written review before you commit? |
498
+ **Then wait**, and `/spec-next` §5 owns the sequencefollow it there rather
499
+ than reading a second copy here: note the moment you start waiting, watch the
500
+ pending store, end your turn, and let `--claim-since` pick the one pass that
501
+ arrived inside the window. The banner it describes is what this skill emits in
502
+ place of a `Review` row, and the routing on the verdict is `/spec-diff` §2 and
503
+ §4, as it is everywhere.
500
504
 
501
- **It ends in a question, addressed to someone.** It was once a fenced block of
502
- engine output, and it fired on every phase and was never once taken: two quoted
503
- lines under the test counts, addressed to nobody, with the report then closing
504
- on *"commit this first"* the last instruction the reader got was to move on,
505
- so they did. A row in a labelled table is findable; a question in it is
506
- answerable. Both halves are load-bearing.
505
+ **Why this skill arms as well as waits.** Waiting is what any offer does;
506
+ **arming** asserts an obligation that outlives the turn, and belongs only to
507
+ work that is finished. A hotfix is a completed unit red→green against a
508
+ released tag so it qualifies, and a wait with nothing owed behind it is a
509
+ suggestion rather than a gate.
507
510
 
508
- **Never bury it and never split it.** It sits above `Follow-ups` and `Next`, and
509
- the page and the question stay in the same row: two adjacent rows about one page
510
- make the reader resolve a distinction before acting on either. A later edit that
511
- moves it out of the block, or separates the link from the question, undoes this
512
- and should be read as a regression rather than tidying.
511
+ **A hotfix is the case where reading it matters most.** This change is about to
512
+ be tagged and shipped to production from a release line, not merged into a
513
+ branch someone else will read first. The verdict is the only review it gets.
513
514
 
515
+ **It is user-visible, and that is deliberate.** Once armed, a `git commit` in
516
+ this worktree is refused until a verdict is sent or
517
+ `skitterspec spec-env review skip "<reason>"` records the decision to move on.
518
+ The exit is always one command, and one of them is *"I am moving on"*.
514
519
 
515
520
  Relay the **`open:`** line rather than the bare path: a path is not clickable in
516
521
  any terminal, and a page nobody can open is a page nobody reads.
@@ -578,10 +583,16 @@ the shape; this section carries only what is specific here.
578
583
  **Fields:** `Tracker` · `Branch` · `Spec` · `Cause` · `Built` · `Tests` ·
579
584
  `Review` · `Follow-ups` · `Next`
580
585
 
586
+ **`Review` is emitted only where step 6b did not run** — a project with no
587
+ isolation, or a render that failed. Where the run is waiting, the banner carries
588
+ the whole subject and the row is dropped, per `.claude/rules/spec-reports.md`.
589
+
581
590
  **The base tag goes in the verdict clause** — `✅ /spec-hotfix · hotfix-foo ·
582
591
  green on v2.3.1`. Which released version this was fixed against is the first
583
592
  thing anyone needs, and it is not a field: the clause is where the run says
584
593
  where it got to.
585
594
 
586
- Step 6b's offer is the `Review` row, not a paragraph after the block — the
587
- counts, the link and the question in one row. Nothing follows the block.
595
+ Step 6b ends in the **banner**, not a paragraph and not a row — the run is
596
+ waiting on a verdict, and the banner is the shape that says so. Nothing follows
597
+ it. Where the run is not waiting it asks nothing at all: a `Review` row carries
598
+ the counts and the link, and no question (*asking implies waiting*).
@@ -105,12 +105,24 @@ Ensure it exists. If missing, create it documenting:
105
105
  Read a sibling spec skill (e.g. `spec`, `spec-next`) for the canonical shapes
106
106
  rather than inventing them. If the rule already exists, leave it unless stale.
107
107
 
108
- ## 4a. The review-gate hook (`.claude/hooks/review-gate.js`)
109
-
110
- `skitterspec init` installs the hook script and registers it in the project's
111
- **committed** `.claude/settings.json` as a `PreToolUse` hook on `Bash`. It runs
112
- one engine call per Bash tool call and refuses a `git commit` in a worktree
113
- whose phase is still awaiting a verdict.
108
+ ## 4a. The review-gate hook (`.claude/hooks/review-gate.cjs`)
109
+
110
+ `skitterspec init` **and `skitterspec update`** install the hook script and
111
+ register it in the project's **committed** `.claude/settings.json` as a
112
+ `PreToolUse` hook on `Bash`. It runs one engine call per Bash tool call and
113
+ refuses a `git commit` in a worktree whose phase is still awaiting a verdict.
114
+
115
+ **Both commands, and that is not a detail.** Copying the script and registering
116
+ it are one operation, and they were once split across two code paths — so
117
+ `update` landed the file, reported `created:`, and wired nothing, on every
118
+ project that upgraded. Landing the script is not installing the hook.
119
+
120
+ **`.cjs`, and the extension is load-bearing.** The script lands inside the
121
+ target project, where *that* project's `package.json` decides how node parses a
122
+ `.js` — so CommonJS shipped as `.js` crashes in any `"type": "module"` project,
123
+ on every Bash tool call. `.cjs` settles it at the file. A registration left over
124
+ from the release that named `.js` is rewritten in place, keeping any wrapping
125
+ the operator added, rather than gaining a second entry beside it.
114
126
 
115
127
  **Committed, not machine-local**, and the difference is the point: the trusted
116
128
  worktree root is one machine's absolute path, while *a phase that ended owes an
@@ -323,6 +323,12 @@ skitterspec spec-env review <spec>
323
323
  the diff never passes through you, so a 266KB patch costs nothing. Report the
324
324
  path it prints and move on.
325
325
 
326
+ **This render takes the committing button set**, which is the default — so
327
+ `--buttons` is not passed. That is a statement about the work, not about the
328
+ gate: the phase is finished, so `Commit` and `Commit & Continue` are the right
329
+ verbs for it. A render part-way through a run takes `--buttons midrun` and
330
+ offers `Continue` instead; `/spec-diff` owns that case.
331
+
326
332
  **Then arm the gate**, so the phase now owes a verdict:
327
333
 
328
334
  ```
@@ -367,21 +373,31 @@ else.
367
373
 
368
374
  ---
369
375
 
370
- **Where you are not waiting, it stays the `Review` row** — the counts, the page
371
- link and a question, in one row:
376
+ **Where you are not waiting, it stays the `Review` row** — the counts and the
377
+ page link, and **no question**:
378
+
379
+ | **Review** | <N> files, +<a> −<d> · [open the page](<the `open:` URL>) |
372
380
 
373
- | **Review** | <N> files, +<a> −<d> · [open the page](<the `open:` URL>) want a written review before you commit? |
381
+ At the end of a phase you are always waiting, so this shape belongs to the
382
+ renders that are not this step: a mid-phase `/spec-diff`, a page produced
383
+ alongside other work. A row cannot be waited on, so a question in one is
384
+ unanswerable however findable it is.
374
385
 
375
386
  **Both shapes are addressed to someone, and that is the constraint.** The offer
376
387
  was once a fenced block of engine output: two quoted lines under the test
377
388
  counts, addressed to nobody, with the report then closing on *"commit this
378
389
  first"* — the last instruction the reader got was to move on, so they did. A row
379
- in a labelled table is findable; a question in it is answerable; a banner says
380
- the work has stopped. What must never come back is something unaddressed,
381
- unfindable, or fenced.
390
+ in a labelled table is findable; a banner says the work has stopped. What must
391
+ never come back is something unaddressed, unfindable, or fenced.
392
+
393
+ **The row asks nothing, and that is not a weakening of the above.** A row cannot
394
+ be waited on, so a question in one is unanswerable however findable it is —
395
+ which is the failure `spec-reports.md` records under *asking implies waiting*.
396
+ The row names the page; the banner is what asks, because the banner is the shape
397
+ the run is standing behind.
382
398
 
383
399
  **Never bury it and never split it.** The row sits above the last two rows of
384
- the block, and the page and the question stay in the same row; the banner
400
+ the block, and the page and its counts stay in the same row; the banner
385
401
  replaces the row rather than joining it. Two places naming one page make the reader
386
402
  resolve a distinction before acting on either — which is the same failure
387
403
  whether the two places are adjacent rows or a row and a banner.
@@ -425,9 +441,15 @@ is that the page can now act, so the token has become a credential rather than
425
441
  a convenience — and `--claim-since` refusing to choose between two passes is
426
442
  what stops a race becoming a wrong commit.
427
443
 
428
- **Where the harness cannot watch a file, change nothing.** The `Review` row and
429
- `/spec-reviewed` are the whole story, exactly as before. The gate still holds
430
- either way: it is the engine's, not the watch's.
444
+ **Where the harness cannot watch a file, you still wait** the turn ending is
445
+ the wait. Say the page is rendered and that you are holding for the pass, then end
446
+ your turn; the reader's next message is what carries it, and `/spec-diff` picks
447
+ it up from the paste exactly as it always has. The gate holds either way: it is
448
+ the engine's, not the watch's.
449
+
450
+ This once read *"change nothing"* — keep the row, keep the question, do not
451
+ wait — and that exemption is the hatch a whole class of unanswerable questions
452
+ came through. Every harness can end a turn.
431
453
 
432
454
  **It still does not break a chained run.** `/commit && /spec-next` is typed as
433
455
  one line; by the time this step is reached the chain has finished, so waiting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "14.0.0",
3
+ "version": "15.0.0",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync \u2014 a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status \u00b7 /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -76,6 +76,8 @@ const {
76
76
  mergeNotes,
77
77
  applyResolutions,
78
78
  COMMITTING,
79
+ BUTTON_SETS,
80
+ DEFAULT_BUTTON_SET,
79
81
  reviewGatePath,
80
82
  readGate,
81
83
  writeGate,
@@ -1633,6 +1635,9 @@ function verdictSaid(v) {
1633
1635
  // thing that will happen to the repo and the reader should see it coming.
1634
1636
  if (v.effective === 'commit') return `committing with ${v.commitWith}`
1635
1637
  if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
1638
+ // Names what it does NOT do, because the reader of a mid-run page has just
1639
+ // pressed a green button and must not read it as a commit.
1640
+ if (v.effective === 'continue') return 'read — carrying on, nothing committed'
1636
1641
  if (v.effective === 'changes') return 'changes requested'
1637
1642
  return 'discuss first'
1638
1643
  }
@@ -1827,6 +1832,20 @@ function specEnvReviewSkip(dir, config, reason, flags) {
1827
1832
  }
1828
1833
 
1829
1834
  async function specEnvReview(dir, config, specArg, flags) {
1835
+ // REFUSED BY NAME, never coerced to the default. A typo'd button set silently
1836
+ // rendering the committing page is the same failure the verdict validator
1837
+ // refuses for the same reason: a caller asking for the mid-run page and
1838
+ // getting the committing one would offer a reader a commit on unfinished
1839
+ // work, and nothing would have said so.
1840
+ if (flags.buttons !== null && !BUTTON_SETS.includes(flags.buttons)) {
1841
+ process.stdout.write(
1842
+ `spec-env review: --buttons ${JSON.stringify(flags.buttons)} is not one of ` +
1843
+ `${BUTTON_SETS.join(', ')} — nothing rendered.\n`,
1844
+ )
1845
+ return
1846
+ }
1847
+ const buttons = flags.buttons || DEFAULT_BUTTON_SET
1848
+
1830
1849
  // An unknown name throws here rather than falling back to the branch: a review
1831
1850
  // of the wrong spec looks exactly like a review of the right one.
1832
1851
  const spec = resolveSpecWithWorktree(dir, config, specArg)
@@ -2166,7 +2185,7 @@ async function specEnvReview(dir, config, specArg, flags) {
2166
2185
  const gate = gateNow.corrupt ? null : gateNow.gate
2167
2186
 
2168
2187
  const now = new Date().toISOString()
2169
- let data = collectReview({ spec, git, mode, ref, base, now, notes, gate })
2188
+ let data = collectReview({ spec, git, mode, ref, base, now, notes, gate, buttons })
2170
2189
 
2171
2190
  // A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
2172
2191
  // ends in: the page is rendered before the commit, the commit happens
@@ -2199,6 +2218,7 @@ async function specEnvReview(dir, config, specArg, flags) {
2199
2218
  now,
2200
2219
  notes,
2201
2220
  gate,
2221
+ buttons,
2202
2222
  fellBack: true,
2203
2223
  })
2204
2224
  if (wider.totals.files > 0) {
@@ -2291,6 +2311,10 @@ async function specEnvReview(dir, config, specArg, flags) {
2291
2311
  urlFile,
2292
2312
  url,
2293
2313
  notesFile: reviewNotesPath(out),
2314
+ // Absent stays absent, exactly as it is in the page payload: the
2315
+ // committing set is what a caller that did not ask always got, so
2316
+ // reporting it would make every existing consumer see a new key.
2317
+ ...(data.buttons ? { buttons: data.buttons } : {}),
2294
2318
  reviewed: Boolean(data.review),
2295
2319
  totals: data.totals,
2296
2320
  notes: data.notes,
@@ -3444,6 +3468,7 @@ async function specEnv(rest) {
3444
3468
  outcome: null,
3445
3469
  claim: null,
3446
3470
  drop: null,
3471
+ buttons: null,
3447
3472
  json: false,
3448
3473
  }
3449
3474
  for (let i = 0; i < args.length; i++) {
@@ -3458,6 +3483,7 @@ async function specEnv(rest) {
3458
3483
  else if (args[i] === '--port') flags.port = args[++i]
3459
3484
  else if (args[i] === '--host') flags.host = args[++i]
3460
3485
  else if (args[i] === '--publish-copy') flags.publishCopy = true
3486
+ else if (args[i] === '--buttons') flags.buttons = args[++i]
3461
3487
  else if (args[i] === '--out') flags.out = args[++i]
3462
3488
  else if (args[i] === '--review') flags.review = args[++i]
3463
3489
  else if (args[i] === '--notes') flags.notes = args[++i]
@@ -3556,13 +3582,14 @@ async function specEnv(rest) {
3556
3582
  break
3557
3583
  default:
3558
3584
  process.stdout.write(
3559
- 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--json] [--record-primary] [--assert-primary-clean]\n' +
3585
+ 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n' +
3560
3586
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
3561
3587
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
3562
3588
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
3563
3589
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
3564
3590
  ' review skip "<reason>" move on without one, on the record\n' +
3565
3591
  ' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
3592
+ ' review [spec] --buttons midrun the page offers Continue, not a commit\n' +
3566
3593
  ' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
3567
3594
  ' in is used, else the sole provisioned spec (several -> it lists them).\n' +
3568
3595
  ' A bare `live` takes that spec when the workbench is free, and prints the\n' +
package/src/env/hooks.js CHANGED
@@ -18,8 +18,21 @@
18
18
  const fs = require('node:fs')
19
19
  const path = require('node:path')
20
20
 
21
- const HOOK_SCRIPT = '.claude/hooks/review-gate.js'
21
+ // `.cjs`, and the extension is load-bearing. This file is copied INTO the target
22
+ // project, where that project's `package.json` decides how node parses a `.js` —
23
+ // so the CommonJS script shipped as `review-gate.js` died on its own first
24
+ // `require` in every `"type": "module"` project, printing a stack trace over
25
+ // every Bash tool call. `.cjs` settles the parse mode at the file, independently
26
+ // of the one file skitterspec does not control.
27
+ const HOOK_STEM = '.claude/hooks/review-gate'
28
+ const HOOK_SCRIPT = `${HOOK_STEM}.cjs`
22
29
  const HOOK_COMMAND = `node "\${CLAUDE_PROJECT_DIR}/${HOOK_SCRIPT}"`
30
+
31
+ // Our script under ANY extension, so a registration naming the retired `.js` is
32
+ // recognised as ours and migrated rather than duplicated. The trailing lookahead
33
+ // is the boundary: without it `review-gate-extra.js` — somebody else's hook —
34
+ // matches on the stem and gets silently rewritten.
35
+ const HOOK_SCRIPT_RE = /[.]claude[/\\]hooks[/\\]review-gate(?:[.][A-Za-z0-9]+)?(?![\w.-])/
23
36
  // Seconds. The engine call behind this is one git-free read of a small JSON
24
37
  // file, so anything approaching this is a wedge rather than slow work — and a
25
38
  // hook that times out fails OPEN, which is the answer we want for a wedge.
@@ -55,13 +68,21 @@ function hookEntry() {
55
68
  * run it twice and look like a bug in the gate.
56
69
  */
57
70
  function alreadyRegistered(preToolUse) {
58
- if (!Array.isArray(preToolUse)) return false
59
- return preToolUse.some(
60
- (group) =>
61
- isObject(group) &&
62
- Array.isArray(group.hooks) &&
63
- group.hooks.some((h) => isObject(h) && typeof h.command === 'string' && h.command.includes(HOOK_SCRIPT)),
64
- )
71
+ return registeredHooks(preToolUse).length > 0
72
+ }
73
+
74
+ // Every hook object under `PreToolUse` whose command names our script, whatever
75
+ // extension it names it under. Pure.
76
+ function registeredHooks(preToolUse) {
77
+ if (!Array.isArray(preToolUse)) return []
78
+ const out = []
79
+ for (const group of preToolUse) {
80
+ if (!isObject(group) || !Array.isArray(group.hooks)) continue
81
+ for (const h of group.hooks) {
82
+ if (isObject(h) && typeof h.command === 'string' && HOOK_SCRIPT_RE.test(h.command)) out.push(h)
83
+ }
84
+ }
85
+ return out
65
86
  }
66
87
 
67
88
  /**
@@ -69,6 +90,7 @@ function alreadyRegistered(preToolUse) {
69
90
  * Idempotent and non-destructive. Returns `{ changed, reason }`:
70
91
  * - `created` — no settings file; one was written
71
92
  * - `added` — merged into an existing file
93
+ * - `migrated` — registered under a retired path; the path was rewritten
72
94
  * - `present` — already registered (no write)
73
95
  * - `malformed` — the file exists but is not parseable JSON (left untouched)
74
96
  */
@@ -98,7 +120,22 @@ function ensureReviewGateHook(dir) {
98
120
 
99
121
  const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
100
122
  const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
101
- if (alreadyRegistered(preToolUse)) return { changed: false, reason: 'present' }
123
+
124
+ // Registered already — but possibly under a path that no longer exists, since
125
+ // the script's extension changed. Rewrite the path IN PLACE rather than
126
+ // replacing the command: an operator who wrapped our hook, added a flag or
127
+ // changed the interpreter has registered it their way, and the thing that
128
+ // moved is the file, not their command. Adding a fresh entry beside theirs
129
+ // would run the gate twice and read as a bug in the gate; leaving the old one
130
+ // alone would aim the harness at a file this same upgrade retires.
131
+ const registered = registeredHooks(preToolUse)
132
+ if (registered.length) {
133
+ const stale = registered.filter((h) => !h.command.includes(HOOK_SCRIPT))
134
+ if (!stale.length) return { changed: false, reason: 'present' }
135
+ for (const h of stale) h.command = h.command.replace(HOOK_SCRIPT_RE, HOOK_SCRIPT)
136
+ writeSettings(file, parsed)
137
+ return { changed: true, reason: 'migrated' }
138
+ }
102
139
 
103
140
  writeSettings(file, {
104
141
  ...parsed,
@@ -110,8 +147,11 @@ function ensureReviewGateHook(dir) {
110
147
  module.exports = {
111
148
  ensureReviewGateHook,
112
149
  alreadyRegistered,
150
+ registeredHooks,
113
151
  hookEntry,
114
152
  settingsPath,
153
+ HOOK_STEM,
115
154
  HOOK_SCRIPT,
155
+ HOOK_SCRIPT_RE,
116
156
  HOOK_COMMAND,
117
157
  }
package/src/env/review.js CHANGED
@@ -230,8 +230,11 @@ function resolveReader(config, env = {}) {
230
230
  * `fellBack` records that `branch` was reached because the working tree was
231
231
  * clean, not because the caller asked for it — see the fallback in
232
232
  * `specEnvReview` (`cli.js`).
233
+ *
234
+ * `buttons` is the button set the page renders — see `BUTTON_SETS`. It is the
235
+ * caller's declaration about the work, not a reading of the gate.
233
236
  */
234
- function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false }) {
237
+ function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null }) {
235
238
  const files = []
236
239
  for (const f of trackedFiles(git, ref)) {
237
240
  const { patch, whole } = patchFor(git, ref, f, false)
@@ -301,6 +304,10 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
301
304
  // Same rule again: a gate that was never armed and never skipped adds no
302
305
  // key at all.
303
306
  ...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
307
+ // THE DEFAULT ADDS NO KEY, so a caller that did not ask for a button set —
308
+ // and a caller that asked for the default by name — renders the payload it
309
+ // rendered before this existed. Opting in is the only thing that shows.
310
+ ...(buttons && buttons !== DEFAULT_BUTTON_SET ? { buttons } : {}),
304
311
  // WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
305
312
  // happen per request — so a page rendered by a stale process looks entirely
306
313
  // right: the counts move, `generatedAt` moves, the diff is correct. Only the
@@ -399,16 +406,46 @@ const NOTES_VERSION = 1
399
406
  * `discuss` is the default because it is the behaviour that existed before any
400
407
  * verdict did. So a blob from an older page, or one a reader sent without
401
408
  * choosing, keeps doing exactly what it always did.
409
+ *
410
+ * `continue` is the mid-run verdict: *I have read it, carry on*. It names an
411
+ * action, which is what separates it from the `none` verdict that was removed —
412
+ * `none` recorded itself and did nothing, while this one resumes the run. What
413
+ * it does NOT do is commit, so it is deliberately absent from `COMMITTING`
414
+ * below and is therefore structurally incapable of clearing an armed gate: a
415
+ * phase that ended still owes a committing verdict or a recorded skip.
402
416
  */
403
- const VERDICTS = ['commit', 'commit-continue', 'changes', 'discuss']
417
+ const VERDICTS = ['commit', 'commit-continue', 'continue', 'changes', 'discuss']
404
418
  const DEFAULT_VERDICT = 'discuss'
405
419
 
406
420
  // The verdicts that COMMIT, and are therefore blocked by an open comment. One
407
- // list, so a fourth verdict cannot become a way around the single refusal this
421
+ // list, so a fifth verdict cannot become a way around the single refusal this
408
422
  // engine makes — adding a committing verdict means adding it here, and the
409
423
  // block follows for free.
424
+ //
425
+ // `continue` IS DELIBERATELY NOT HERE, and that omission is the whole of
426
+ // decision 2: waiting is what any offer does, while arming asserts an
427
+ // obligation that outlives the turn. A mid-run reader saying "carry on" has
428
+ // answered the offer in front of them and nothing else, so the gate a finished
429
+ // phase armed must survive it untouched.
410
430
  const COMMITTING = ['commit', 'commit-continue']
411
431
 
432
+ /**
433
+ * Which set of buttons a rendered page shows.
434
+ *
435
+ * DECLARED BY THE CALLER, NEVER DERIVED FROM THE GATE. Deriving it — mid-run
436
+ * iff the gate is unarmed — is tidier and wrong: a project running
437
+ * `review.required: false` never arms at all, so every one of its pages would
438
+ * lose the committing buttons and the reader could never commit from the page.
439
+ * The caller knows whether the work it just rendered is finished; the gate only
440
+ * knows whether this project opted into gating.
441
+ *
442
+ * `committing` is the default, so a caller that says nothing keeps today's page
443
+ * exactly — the key is left off the payload entirely rather than written out as
444
+ * the default, so an unchanged caller renders an unchanged page.
445
+ */
446
+ const BUTTON_SETS = ['committing', 'midrun']
447
+ const DEFAULT_BUTTON_SET = 'committing'
448
+
412
449
  /**
413
450
  * What an older sidecar's `approve` means now. Pure.
414
451
  *
@@ -1465,6 +1502,8 @@ module.exports = {
1465
1502
  NOTES_VERSION,
1466
1503
  VERDICTS,
1467
1504
  COMMITTING,
1505
+ BUTTON_SETS,
1506
+ DEFAULT_BUTTON_SET,
1468
1507
  readVerdict,
1469
1508
  DEFAULT_VERDICT,
1470
1509
  DELETED_HASH,
package/src/init.js CHANGED
@@ -39,14 +39,23 @@ function listCommands() {
39
39
  }
40
40
  }
41
41
 
42
- // Hook scripts shipped as `assets/hooks/*.js`, installed to `.claude/hooks/`.
43
- // Discovered from the bundled tree like everything else, so a distribution
44
- // installs precisely what it ships and a hook can be retired by deleting it.
42
+ // Hook scripts shipped as `assets/hooks/*.cjs` (or `*.mjs`), installed to
43
+ // `.claude/hooks/`. Discovered from the bundled tree like everything else, so a
44
+ // distribution installs precisely what it ships and a hook can be retired by
45
+ // deleting it.
46
+ //
47
+ // A BARE `.js` IS DELIBERATELY NOT DISCOVERED. These files are copied into the
48
+ // TARGET project, where that project's `package.json` — the one file skitterspec
49
+ // does not control — decides how node parses a `.js`. Shipping CommonJS as `.js`
50
+ // crashed the review-gate hook in every `"type": "module"` project, on every
51
+ // Bash tool call. The extension is the only thing that settles it at the file,
52
+ // so both accepted forms pin it; `env-review-hook.test.js` asserts the rule over
53
+ // the whole directory rather than over this filter.
45
54
  function listHooks() {
46
55
  try {
47
56
  return fs
48
57
  .readdirSync(path.join(ASSETS, 'hooks'))
49
- .filter((f) => f.endsWith('.js'))
58
+ .filter((f) => f.endsWith('.cjs') || f.endsWith('.mjs'))
50
59
  .sort()
51
60
  } catch {
52
61
  return [] // a distribution may ship no hooks
@@ -412,6 +421,12 @@ function registerReviewGateHook(dir) {
412
421
  report.created.push(label)
413
422
  } else if (res.reason === 'added') {
414
423
  report.updated.push(label)
424
+ } else if (res.reason === 'migrated') {
425
+ // A WRITE, so it may not fall through to `skipped`. It was doing exactly
426
+ // that: rewriting the registered path and then reporting "already
427
+ // registered" — a run that acts and says it did not, which is the shape of
428
+ // the bug this whole change exists to fix.
429
+ report.updated.push('.claude/settings.json (review-gate hook repointed at the renamed script)')
415
430
  } else {
416
431
  report.skipped.push('.claude/settings.json (review-gate hook already registered)')
417
432
  }
@@ -728,6 +743,15 @@ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
728
743
  installFolders(dir)
729
744
  removeRetiredFiles(dir)
730
745
  pruneRetiredManaged(dir, manifest)
746
+ // The hook SCRIPT arrives above, as one more managed target; registering it is
747
+ // a separate write to a file we do not manage, so it has to be asked for here.
748
+ // It is easy to read this as duplication of `installHooks()` and delete it —
749
+ // it is not. `installHooks()` is unreachable from this path, and the two
750
+ // halves being in different functions is exactly how they drifted apart once:
751
+ // `update` copied `.claude/hooks/` and registered nothing, for every upgrading
752
+ // project, while reporting a file created. `init-review-gate-hook.test.js`
753
+ // asserts over the entry points rather than over this call.
754
+ registerReviewGateHook(dir)
731
755
  if (claudeMd) installClaudeMd(dir, { mode: 'update' })
732
756
  flushManifest(dir)
733
757
  printReport(dir, 'resync', { diff })
@@ -36,7 +36,7 @@ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
36
36
  // An issue identifier: team key, dash, number. Matched CASE-INSENSITIVELY
37
37
  // because `linear_url` carries the identifier lowercased in its path
38
38
  // (`…/issue/reu-188/retire-…`). Matching only uppercase left 29 of 33 real URLs
39
- // in ~/code/ereqs pointing at the old key — the same ones the hand-repair
39
+ // in a live repo pointing at the old key — the same ones the hand-repair
40
40
  // missed. A rewrite only happens when the key matches `oldKey`, so an unrelated
41
41
  // token like `utf-8` is never in scope.
42
42
  const IDENTIFIER_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\b/g
@@ -2,8 +2,8 @@
2
2
 
3
3
  // Task bullets in a phase file are hand-wrapped prose, not single lines:
4
4
  //
5
- // - [x] Add `DbProcessEventOutbox` to `prisma/schema.prisma`, modelled on
6
- // `DbNotificationOutbox`: status, attempts, `nextAttemptAt`, …
5
+ // - [x] Add the retry column to the outbox table, modelled on the existing
6
+ // queue: status, attempts, `nextAttemptAt`, …
7
7
  //
8
8
  // A Linear issue title is single-line, so the two representations differ by
9
9
  // wrapping alone. This module is the one place that converts between them: