@skitterbyte/skitterspec 21.0.0 → 22.1.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.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
package/src/env/review.js CHANGED
@@ -44,6 +44,24 @@ function isNoise(relPath) {
44
44
  return relPath.startsWith('specs/') || relPath.startsWith('.spec-env/')
45
45
  }
46
46
 
47
+ /**
48
+ * Is this path one of `folder`'s own spec documents, in any bucket?
49
+ *
50
+ * Every bucket, for the same reason `classifyDirtyTree` checks every bucket: a
51
+ * tree mid-`git mv` is dirty in two of them at once and both halves are the
52
+ * same spec's.
53
+ *
54
+ * It exists because `isNoise` above is exactly wrong for the `docs` mode. That
55
+ * rule — everything under `specs/` is bookkeeping — is right for a phase's code
56
+ * diff, where the spec's own checkbox edits are not what anyone came to read.
57
+ * On a page whose SUBJECT is the spec, applying it folds away every file and
58
+ * renders a page with nothing open on it.
59
+ */
60
+ function isSpecDocOf(relPath, folder) {
61
+ const parts = String(relPath).split('/')
62
+ return parts.length > 3 && parts[0] === 'specs' && parts[2] === folder
63
+ }
64
+
47
65
  /**
48
66
  * A git runner bound to one checkout, returning stdout **untrimmed**.
49
67
  *
@@ -174,12 +192,21 @@ function numstatFor(git, ref, file, untracked) {
174
192
  * `remote` — somewhere else, so it does not.
175
193
  * `unknown` — CANNOT TELL, and that is a real answer rather than a soft `local`.
176
194
  *
177
- * **This decides wording and nothing else.** Nothing in the engine serves,
178
- * publishes or refuses on the strength of it, because being wrong has to stay
179
- * cheap in both directions: a wrong `local` prints a dead link (the bug this
180
- * exists to fix), and a wrong `remote` acted upon would publish something the
181
- * tooling cannot remove, unprompted. `unknown` is therefore wired to exactly the
182
- * behaviour that existed before any of this.
195
+ * **This decides two things: the WORDING, and WHAT THE SERVER BINDS TO.** It
196
+ * does not decide *whether* to serve — `review.serve` does — and it never
197
+ * decides to publish. Being wrong therefore stays cheap in both directions: a
198
+ * wrong `local` binds loopback, which is reachable from the machine holding the
199
+ * page and says so; a wrong `remote` binds every interface, which is the
200
+ * existing behaviour and is announced on the render.
201
+ *
202
+ * IT DID DECIDE WHETHER TO SERVE, ONCE, and this comment claimed otherwise
203
+ * throughout — the sentence above used to read "nothing in the engine serves on
204
+ * the strength of it" while `cli.js` served only for a `remote` reader. The bill
205
+ * was a `file://` link on a local machine, and a `file://` page has no server to
206
+ * POST to, so the verdict buttons on it had nowhere to go: the loop the page
207
+ * exists to close was absent from the sessions easiest to use. If you are here
208
+ * wondering whether to make detection load-bearing again, that is what it cost
209
+ * the last time, and `env-serve-start-proof` now fails if you do.
183
210
  *
184
211
  * `env` is passed in, never read from `process` here, so a test states the world
185
212
  * it is testing instead of inheriting the machine the suite happens to run on.
@@ -234,17 +261,44 @@ function resolveReader(config, env = {}) {
234
261
  * `buttons` is the button set the page renders — see `BUTTON_SETS`. It is the
235
262
  * caller's declaration about the work, not a reading of the gate.
236
263
  */
237
- function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null }) {
264
+ function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null, only = null, treePath = null, live = null, tiers = null }) {
265
+ // WHICH TREE THE SPEC'S OWN DOCUMENTS ARE READ FROM. Every render but one
266
+ // reads the spec's worktree, and for those the two are the same path — so
267
+ // `treePath` left null keeps the existing behaviour exactly. The `docs` mode
268
+ // has no worktree to read, which is the whole reason it exists.
269
+ const root = treePath || spec.worktreePath
270
+
271
+ // AN EXPLICIT FILE SET, or every changed file. `only` is a whitelist of
272
+ // repo-relative paths, and it exists because the `docs` mode reads a tree
273
+ // several sessions write into: rendering everything uncommitted there would
274
+ // put another spec's documents on this page and then commit them under this
275
+ // page's verdict. Null — every other caller — filters nothing.
276
+ const wanted = only ? new Set(only) : null
277
+ const keep = (f) => !wanted || wanted.has(f.path)
278
+
279
+ // WHAT COUNTS AS BOOKKEEPING, and it inverts for the `docs` mode. Everywhere
280
+ // else the spec's own documents are the bookkeeping beside the code; on a
281
+ // docs page they are the code, and the companions the project declared (a
282
+ // tracker snapshot, say) are what belongs folded away.
283
+ const noiseOf = (p) => (mode === 'docs' ? !isSpecDocOf(p, spec.folder) : isNoise(p))
284
+
285
+ // THE SURFACES BLOCK, built here so the page never has to know which tiers
286
+ // exist or which of them can be turned on. `null` when the caller passed
287
+ // neither a live state nor a stack — an absent key, not an empty one.
288
+ const surfaces = surfacesFor({ live, tiers, buttons })
289
+
238
290
  const files = []
239
291
  for (const f of trackedFiles(git, ref)) {
292
+ if (!keep(f)) continue
240
293
  const { patch, whole } = patchFor(git, ref, f, false)
241
294
  const { additions, deletions, binary } = numstatFor(git, ref, f, false)
242
- files.push({ ...f, additions, deletions, binary, whole, noise: isNoise(f.path), patch })
295
+ files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
243
296
  }
244
297
  for (const f of untrackedFiles(git)) {
298
+ if (!keep(f)) continue
245
299
  const { patch, whole } = patchFor(git, ref, f, true)
246
300
  const { additions, deletions, binary } = numstatFor(git, ref, f, true)
247
- files.push({ ...f, additions, deletions, binary, whole, noise: isNoise(f.path), patch })
301
+ files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
248
302
  }
249
303
 
250
304
  // Content hashes and the stored review state, folded on before the totals so
@@ -261,7 +315,7 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
261
315
  // Every bucket, because a page is rendered for specs in `in-progress/` and for
262
316
  // finished ones in `complete/` — and the finished one is the case this exists
263
317
  // for. `null` when it cannot tell, and the page leaves its button alone.
264
- const specDir = spec.worktreePath ? findSpecDirIn(spec.worktreePath, spec.folder) : null
318
+ const specDir = root ? findSpecDirIn(root, spec.folder) : null
265
319
  const phases = specDir ? readPhases(specDir) : null
266
320
  // The PR description this page never had: why the change exists, what it
267
321
  // touches, and what this phase set out to do.
@@ -280,7 +334,11 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
280
334
  spec: spec.folder,
281
335
  title: spec.folder,
282
336
  branch: spec.branch,
283
- worktree: spec.worktreePath,
337
+ // The tree the diff was read from. Identical to the worktree for every
338
+ // mode but `docs`, where there is no worktree and this is the checkout the
339
+ // documents actually live in — saying `worktree` there would name a path
340
+ // that does not exist.
341
+ worktree: root,
284
342
  mode,
285
343
  base,
286
344
  ref,
@@ -304,6 +362,27 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
304
362
  // Same rule again: a gate that was never armed and never skipped adds no
305
363
  // key at all.
306
364
  ...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
365
+ // THE PATHS A COMMITTING VERDICT HERE MUST COMMIT, carried on the payload
366
+ // so the skill that routes on the verdict never recomputes them. It matters
367
+ // that they travel with the page rather than being asked for again: a
368
+ // checkout is shared, so the set of this spec's uncommitted documents can
369
+ // differ between the render and the verdict — and the reader's conclusion is
370
+ // about what the page showed them.
371
+ //
372
+ // Absent for every other mode, which is what keeps their payloads identical.
373
+ ...(only ? { docs: { paths: only } } : {}),
374
+ // WHERE ELSE THIS REVIEW CAN BE REACHED, and whether it is also running.
375
+ //
376
+ // The page had no idea the other tiers existed — it is opened AT one URL
377
+ // and knows nothing about the rest — so an enable press needs somewhere to
378
+ // live, and that is this block. It carries only what the reader can act on:
379
+ // the live state, each tier that is OFF with the action that turns it on,
380
+ // and the `network` URL when it is on, which is the one address a reader
381
+ // wants that is not the one they are standing on.
382
+ //
383
+ // ABSENT STAYS ABSENT, exactly as `phases` and `gate` do: a caller that
384
+ // passes neither renders the payload it rendered before this existed.
385
+ ...(surfaces ? { surfaces } : {}),
307
386
  // THE DEFAULT ADDS NO KEY, so a caller that did not ask for a button set —
308
387
  // and a caller that asked for the default by name — renders the payload it
309
388
  // rendered before this existed. Opting in is the only thing that shows.
@@ -401,12 +480,28 @@ const NOTES_VERSION = 1
401
480
  * THE VERDICT NAMES THE ACTION. It was `approve` once, and an approval that
402
481
  * only recorded itself is the one thing on a review page that does not describe
403
482
  * what happens — a review is the guard in front of an action, so the word is
404
- * the action: `commit`, `commit-continue`, `changes`, `discuss`.
483
+ * the action: `commit`, `commit-continue`, `commit-start`, `changes`,
484
+ * `discuss`.
485
+ *
486
+ * `commit-start` is the authoring verdict: commit the spec that was just
487
+ * written, then put it in flight. It is its own word rather than a
488
+ * context-dependent reading of `commit-continue` for exactly the reason above —
489
+ * a verdict that means `/spec-start` on one page and `/spec-next` on another
490
+ * names neither, and the outcome log would record the same word for two
491
+ * different actions with no way to tell them apart afterwards.
405
492
  *
406
493
  * `discuss` is the default because it is the behaviour that existed before any
407
494
  * verdict did. So a blob from an older page, or one a reader sent without
408
495
  * choosing, keeps doing exactly what it always did.
409
496
  *
497
+ * `commit-land` is the `/no-spec` verdict: commit this work, fast-forward the
498
+ * base branch to it, and tear the worktree down. It is its own word for exactly
499
+ * the reason `commit-start` is — reusing plain `commit` would make one verdict
500
+ * mean "commit and stop" on a phase page and "commit, land and destroy the
501
+ * worktree" on a `/no-spec` page, which is the context-dependent reading this
502
+ * vocabulary exists to prevent. The outcome log would then record the same word
503
+ * for two very differently destructive actions.
504
+ *
410
505
  * `continue` is the mid-run verdict: *I have read it, carry on*. It names an
411
506
  * action, which is what separates it from the `none` verdict that was removed —
412
507
  * `none` recorded itself and did nothing, while this one resumes the run. What
@@ -414,9 +509,222 @@ const NOTES_VERSION = 1
414
509
  * below and is therefore structurally incapable of clearing an armed gate: a
415
510
  * phase that ended still owes a committing verdict or a recorded skip.
416
511
  */
417
- const VERDICTS = ['commit', 'commit-continue', 'continue', 'changes', 'discuss']
512
+ const VERDICTS = [
513
+ 'commit',
514
+ 'commit-continue',
515
+ 'commit-start',
516
+ 'commit-land',
517
+ 'continue',
518
+ 'changes',
519
+ 'discuss',
520
+ ]
418
521
  const DEFAULT_VERDICT = 'discuss'
419
522
 
523
+ /**
524
+ * Actions the page can send INSTEAD of a verdict.
525
+ *
526
+ * AN ACTION IS NOT A VERDICT, and keeping the two vocabularies apart is the
527
+ * whole point rather than tidiness. A verdict is a conclusion about the work,
528
+ * consumed once by the thing it asked for. An action changes what is *running*
529
+ * — or which tiers are permitted — and then puts the reader back on the same
530
+ * page with the same options, so a run answering one has concluded nothing.
531
+ *
532
+ * THERE IS NO `live-off`, and its absence is the decision. Putting *this* change
533
+ * live is about the diff on screen; handing the whole instance back to `main` is
534
+ * a workspace decision with nothing to do with this review, and a control for it
535
+ * on a review page invites the reader to make it while thinking about something
536
+ * else. The `live:` line names `/spec-live main` instead, which is what a person
537
+ * types.
538
+ *
539
+ * Three consequences follow from that, and all three are structural rather than
540
+ * remembered:
541
+ *
542
+ * - It is absent from `VERDICTS`, so nothing that routes on a verdict can see
543
+ * it.
544
+ * - It is absent from `COMMITTING`, so it is **incapable** of clearing an armed
545
+ * gate: a phase that ended still owes a verdict after the reader has looked
546
+ * at it running. The commit that a `live-on` performs first is the mechanical
547
+ * precondition for `live take` (which refuses a dirty worktree), not the
548
+ * reader's answer.
549
+ * - A blob may carry a verdict or an action, never both. Two answers in one
550
+ * pass would need a rule about which wins, and any such rule would sometimes
551
+ * act on the half the reader did not mean.
552
+ *
553
+ * `allow-network`/`allow-remote` turn a review TIER on, and they exist here
554
+ * because the page can now act — which is the premise
555
+ * `feat-three-review-links` decision 5 rejected a page toggle on, having
556
+ * recorded it as open to reopening. Two things replace the limit it rested on.
557
+ * **Only a reader who can already reach the page can press one**, so widening
558
+ * loopback→network can only be pressed from the machine itself, where the
559
+ * command was available anyway, and the serve token still decides who reaches
560
+ * the page at all. And `allow-remote` **permits publishing without
561
+ * publishing** — the permanent claude.ai page still takes an explicit ask.
562
+ *
563
+ * There is no disable direction, and `tierAction` is where that is enforced:
564
+ * turning `network` off from a page reached over the network kills the page
565
+ * doing the turning.
566
+ */
567
+ const ACTIONS = ['live-on', 'allow-network', 'allow-remote']
568
+
569
+ /**
570
+ * The action a tier that is OFF offers, or null.
571
+ *
572
+ * ENABLE-ONLY, and that is a decision rather than an omission: turning
573
+ * `network` off from a page you reached over the network kills the page doing
574
+ * the turning, and the reader gains nothing they could not get by typing the
575
+ * command. `local` has no setting at all — it is the machine the page is on.
576
+ */
577
+ function tierAction(tier) {
578
+ if (!tier || !tier.off) return null
579
+ if (tier.tier === 'network') return 'allow-network'
580
+ if (tier.tier === 'remote') return 'allow-remote'
581
+ return null
582
+ }
583
+
584
+ /**
585
+ * What the page shows above the verdicts: where this review can be reached, and
586
+ * whether it is also running. Pure.
587
+ *
588
+ * ONLY WHAT THE READER CAN ACT ON. An `on` tier the reader is already standing
589
+ * on is not information — they are reading it — so the only tier URL carried is
590
+ * `network`'s, which is the one address someone on a laptop wants for their
591
+ * phone. Everything else here is a state plus the one press that changes it.
592
+ *
593
+ * `null` RATHER THAN AN EMPTY BLOCK when there is nothing to say: an
594
+ * `unavailable` live state and no stack means a caller that never asked, and
595
+ * the payload must then be byte-identical to what it was before this key
596
+ * existed.
597
+ */
598
+ function surfacesFor({ live, tiers, buttons = null }) {
599
+ // A MID-PHASE PAGE CANNOT PUT ANYTHING LIVE, so it is not offered the press.
600
+ //
601
+ // `live take` refuses a dirty worktree, so the action commits the phase
602
+ // first — and `/spec-diff` §2b is explicit that half a phase is not
603
+ // committed to look at it running: that splits one phase across two commits
604
+ // and leaves a mess nobody asked for. So mid-run the press could only ever be
605
+ // declined, and a control that always refuses teaches the reader that the
606
+ // buttons on this page are decorative — the same failure
607
+ // `.claude/rules/spec-reports.md` records for the offer nothing was watching.
608
+ //
609
+ // The row stays, with the command in place of the button: the reader still
610
+ // learns the surface exists and what to type, which is exactly the trade the
611
+ // strip already makes on a `file://` page.
612
+ //
613
+ // ONLY `live-on`. `allow-network` and `allow-remote` write a config file
614
+ // and commit nothing, so they are as valid mid-phase as at the end of one.
615
+ const canCommit = buttons !== 'midrun'
616
+ const rows = []
617
+
618
+ // `unavailable` contributes nothing — the cannot-tell state, routed to
619
+ // silence exactly as `liveStateLine` routes it (`env/live.js`).
620
+ if (live && live.state && live.state !== 'unavailable') {
621
+ rows.push({
622
+ kind: 'live',
623
+ state: live.state,
624
+ url: live.url || null,
625
+ reason: live.reason || null,
626
+ // ONE DIRECTION ONLY. `off` offers the press; `on` and `held` offer none —
627
+ // `on` because handing the instance back to `main` is not this review's
628
+ // business, and `held` because the way out is another spec's to take and a
629
+ // button here would either park someone else's work or do nothing.
630
+ action: live.state === 'off' && canCommit ? 'live-on' : null,
631
+ // The command for the direction the page does not offer, so the reader is
632
+ // never left knowing what they want and not what to type. Mid-run that is
633
+ // `/spec-live` itself, since the press is withheld above.
634
+ command:
635
+ live.state === 'on' ? '/spec-live main' : live.state === 'off' && !canCommit ? '/spec-live' : null,
636
+ })
637
+ }
638
+
639
+ for (const t of tiers || []) {
640
+ const action = tierAction(t)
641
+ // ONLY WHAT THE READER CAN ACT ON. An `on` tier contributes nothing: the
642
+ // reader is standing on one of them, and the terminal render is where they
643
+ // got the link — printing it again here would be two places naming one page,
644
+ // which is the split `.claude/rules/spec-reports.md` records as a failure.
645
+ if (!action) continue
646
+ rows.push({
647
+ kind: 'tier',
648
+ tier: t.tier,
649
+ state: 'off',
650
+ url: null,
651
+ // SAID BEFORE THE PRESS, not after it. Unlike the live action, which
652
+ // touches no tracked file, `allow` edits `specs/.core/env.config.json` in
653
+ // the primary checkout — changing behaviour for everyone who pulls and
654
+ // leaving that tree dirty. A setting change of that reach must not land
655
+ // because someone tapped a button labelled only `Allow remote`.
656
+ note: t.note || 'writes env.config.json — committed, and shared',
657
+ // The same thing from a terminal, for the reader who is nowhere near the
658
+ // page. Only `remote` has a command of its own: `network` is ON by
659
+ // default, so turning it off is a rare deliberate act and a slash command
660
+ // per tier is clutter for the one nobody touches.
661
+ command: t.tier === 'remote' ? '/spec-remote-review' : null,
662
+ action,
663
+ })
664
+ }
665
+
666
+ return rows.length ? rows : null
667
+ }
668
+
669
+ /**
670
+ * The three review tiers, in a FIXED ORDER, each either a URL or the reason it
671
+ * is not one. Pure: it takes what the caller already resolved.
672
+ *
673
+ * Fixed order matters more than it looks. A reader who has learnt which line
674
+ * their phone opens should not have to re-read the labels every render, and a
675
+ * stack that reordered itself by availability would make them.
676
+ *
677
+ * WHY A STACK AT ALL, when the rule said exactly one link: the engine cannot
678
+ * know where the reader is sitting, and every attempt to guess failed the
679
+ * moment they moved. `local` and `network` are two doors into ONE room — the
680
+ * page POSTs to `location.pathname`, so both reach the same server and the same
681
+ * pending store, and one wait covers both. `remote` is a second store, which is
682
+ * why it carries the line saying so.
683
+ */
684
+ function reviewTierStack({ served, fileUrl, publishedUrl, config }) {
685
+ const tiers = []
686
+ const loopbackUrl = served && served.loopbackUrl ? served.loopbackUrl : null
687
+
688
+ tiers.push(
689
+ loopbackUrl
690
+ ? { tier: 'local', url: loopbackUrl }
691
+ : { tier: 'local', url: fileUrl, note: 'a file:// page cannot send a verdict' },
692
+ )
693
+
694
+ if (!config.review.allowNetwork) {
695
+ tiers.push({ tier: 'network', off: true, enable: 'skitterspec spec-env review allow network' })
696
+ } else if (served && !served.loopback && served.url) {
697
+ tiers.push({ tier: 'network', url: served.url, alternates: served.alternates || [] })
698
+ } else {
699
+ // Permitted, but there is no address to offer — a machine with no network,
700
+ // or a server that could not take one. Not a problem to report as an error.
701
+ tiers.push({ tier: 'network', unavailable: true, note: 'no network address on this machine' })
702
+ }
703
+
704
+ if (!config.review.allowRemote) {
705
+ // THE COMMAND A PERSON TYPES. `/spec-remote-review` toggles, so the same
706
+ // line works whichever way the tier currently is — and nobody has to
707
+ // reconstruct `spec-env review allow remote` from a render they are reading
708
+ // on a phone.
709
+ tiers.push({ tier: 'remote', off: true, enable: '/spec-remote-review' })
710
+ } else if (publishedUrl) {
711
+ tiers.push({ tier: 'remote', url: publishedUrl, note: 'a verdict here needs /spec-reviewed' })
712
+ } else {
713
+ tiers.push({ tier: 'remote', unavailable: true, note: 'publishing is an ask — nothing published yet' })
714
+ }
715
+
716
+ return tiers
717
+ }
718
+
719
+ // One tier as a line, padded so the labels form a column the eye can run down.
720
+ function reviewTierLine(t) {
721
+ const label = ` ${t.tier}:`.padEnd(11)
722
+ if (t.url) return `${label}${t.url}${t.note ? ` (${t.note})` : ''}`
723
+ if (t.off) return `${label}off — turn on with: ${t.enable}`
724
+ return `${label}— (${t.note})`
725
+ }
726
+
727
+
420
728
  // The verdicts that COMMIT, and are therefore blocked by an open comment. One
421
729
  // list, so a fifth verdict cannot become a way around the single refusal this
422
730
  // engine makes — adding a committing verdict means adding it here, and the
@@ -427,7 +735,7 @@ const DEFAULT_VERDICT = 'discuss'
427
735
  // obligation that outlives the turn. A mid-run reader saying "carry on" has
428
736
  // answered the offer in front of them and nothing else, so the gate a finished
429
737
  // phase armed must survive it untouched.
430
- const COMMITTING = ['commit', 'commit-continue']
738
+ const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
431
739
 
432
740
  /**
433
741
  * Which set of buttons a rendered page shows.
@@ -442,8 +750,32 @@ const COMMITTING = ['commit', 'commit-continue']
442
750
  * `committing` is the default, so a caller that says nothing keeps today's page
443
751
  * exactly — the key is left off the payload entirely rather than written out as
444
752
  * the default, so an unchanged caller renders an unchanged page.
753
+ *
754
+ * `authoring` is the set for a spec that has just been written: its committing
755
+ * pair is `commit-start` and `commit` — put it in flight now, or keep it for
756
+ * later. `commit-continue` is absent because there is no phase in flight to
757
+ * continue, and `continue` is absent because the run has nothing left to resume.
758
+ *
759
+ * `refresh` is the set for a spec that was re-validated rather than written:
760
+ * `commit`, `changes`, `discuss`, and NO start verdict. A refreshed spec may
761
+ * already be in progress, so offering to put it in flight would be wrong for
762
+ * half this set's inputs — and wrong in the expensive direction, since it would
763
+ * offer to provision a worktree for a spec that already has one.
764
+ *
765
+ * `nospec` is the set for work with no spec document: its committing pair is
766
+ * `commit-land` and `commit` — finish it now, or commit and leave the branch
767
+ * standing. `commit-continue` is absent because there is no next phase, and
768
+ * `commit-start` because there is nothing to put in flight.
769
+ *
770
+ * REUSING `refresh` HERE WAS THE OBVIOUS MOVE AND IT IS WRONG. The three
771
+ * remaining buttons are identical, so the temptation is to let `/no-spec` take
772
+ * that set and treat plain `commit` as "commit, land, tear down". That makes one
773
+ * verdict word mean two different things depending on which skill rendered the
774
+ * page — precisely what having separate words for `commit-start` and
775
+ * `commit-continue` exists to avoid, and worse here, because one of the two
776
+ * readings destroys a worktree.
445
777
  */
446
- const BUTTON_SETS = ['committing', 'midrun']
778
+ const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec']
447
779
  const DEFAULT_BUTTON_SET = 'committing'
448
780
 
449
781
  /**
@@ -584,9 +916,13 @@ function writeNotes(outPath, notes) {
584
916
 
585
917
  const PENDING_VERSION = 1
586
918
 
587
- // Beside the page and the notes sidecar, under gitignored `.spec-env/`.
919
+ // Beside the page and the notes sidecar, under gitignored `.spec-env/`. The
920
+ // suffix is named once so the cross-spec scan recognises a store by the same
921
+ // spelling that writes one.
922
+ const PENDING_SUFFIX = '.pending.json'
923
+
588
924
  function reviewPendingPath(outPath) {
589
- return outPath.replace(/\.html$/, '') + '.pending.json'
925
+ return outPath.replace(/\.html$/, '') + PENDING_SUFFIX
590
926
  }
591
927
 
592
928
  function emptyPending(specFolder) {
@@ -674,6 +1010,59 @@ function addPending(pending, { blob, at, render }, mint = mintPendingCode) {
674
1010
  return { pending: next, code }
675
1011
  }
676
1012
 
1013
+ /**
1014
+ * What became of ONE pass, by its code. Reads; writes nothing, refuses nothing.
1015
+ *
1016
+ * The served page POSTs, is handed six digits, and then has to end on one of
1017
+ * two sentences — Claude picked this up, or here is the command that picks it
1018
+ * up. It cannot know which at send time, so it asks, and this answers.
1019
+ *
1020
+ * Three states, and the third is the point:
1021
+ *
1022
+ * - `waiting` — the code IS in the holding area. Nobody has claimed it.
1023
+ * - `claimed` — the decision log NAMES the code. Somebody did.
1024
+ * - `unknown` — neither could be established.
1025
+ *
1026
+ * BOTH ANSWERS REST ON SOMETHING PRESENT, which is what stops this being the
1027
+ * fourth row in the table at the top of `.claude/rules/negative-checks.md`. The
1028
+ * tempting cheap version reads `claimed` off the code being ABSENT from the
1029
+ * holding area — and absence there has four causes, of which exactly one is a
1030
+ * claim: `--drop` removes a pass and writes no decision, a store written under
1031
+ * a different `--out` is a store this never opens, and a mistyped folder finds
1032
+ * an empty one. Three of those four would report a pass as picked up while it
1033
+ * sits in a file nobody is reading, which is this spec's own bug told back to
1034
+ * the reader with confidence.
1035
+ *
1036
+ * So `unknown` answers everything that is not a positive signal, and the caller
1037
+ * turns it back into the command. That direction is deliberate: a command
1038
+ * nobody needed to run costs a glance, and a pass nobody claims costs the
1039
+ * review.
1040
+ *
1041
+ * A code that is not six digits never reaches disk. It cannot be one this
1042
+ * engine minted, so there is nothing to look up, and answering it from the
1043
+ * filesystem would make a reachable endpoint into a path-shaped probe.
1044
+ */
1045
+ function passState(outPath, specFolder, code) {
1046
+ const wanted = typeof code === 'string' ? code.trim() : ''
1047
+ if (!new RegExp(`^\\d{${PENDING_CODE_LENGTH}}$`).test(wanted)) return { state: 'unknown' }
1048
+
1049
+ const held = readPending(outPath, specFolder)
1050
+ // A store we could not parse is not an empty one. It holds somebody's passes
1051
+ // and we simply cannot see them, so neither answer is available.
1052
+ if (held.corrupt) return { state: 'unknown' }
1053
+ if ((held.pending.passes || []).some((p) => p && p.code === wanted)) return { state: 'waiting' }
1054
+
1055
+ const stored = readNotes(outPath, specFolder)
1056
+ if (stored.corrupt) return { state: 'unknown' }
1057
+ const decisions = Array.isArray(stored.notes.decisions) ? stored.notes.decisions : []
1058
+ if (decisions.some((d) => d && d.code === wanted)) return { state: 'claimed' }
1059
+
1060
+ // Gone from the holding area and unnamed in the log. It may have been
1061
+ // dropped, it may predate this field, the store may not be the one the page
1062
+ // was served from. Say so.
1063
+ return { state: 'unknown' }
1064
+ }
1065
+
677
1066
  /**
678
1067
  * What is waiting, as the render should describe it. Pure.
679
1068
  *
@@ -695,6 +1084,11 @@ function describePending(pending) {
695
1084
  // `approve`, and the operator must be offered the word that describes
696
1085
  // what claiming it would do.
697
1086
  verdict: readVerdict((p.blob && p.blob.verdict) || null) || null,
1087
+ // AN ACTION IS REPORTED AS AN ACTION. A pass carrying one has no verdict,
1088
+ // and listing it as `no verdict` would describe the instruction it does
1089
+ // carry as an absence — which is the reading that makes a reader claim it
1090
+ // expecting a conclusion.
1091
+ action: (p.blob && p.blob.action) || null,
698
1092
  at: p.at || null,
699
1093
  }))
700
1094
  .sort((a, b) => String(a.at).localeCompare(String(b.at)) || a.code.localeCompare(b.code))
@@ -782,6 +1176,185 @@ function passesSince(pending, since) {
782
1176
  return { codes, usable: true }
783
1177
  }
784
1178
 
1179
+ /* ==========================================================================
1180
+ * The wait — one implementation, because improvised ones cannot be tested
1181
+ *
1182
+ * The skills used to say "watch the pending store and end your turn" and stop
1183
+ * there, so every run wrote its own watcher in shell. Three failed in two days,
1184
+ * each reaching the operator as "I pressed the button and nothing happened" —
1185
+ * and the worst of them was `until [ -f "$P" ] && [ "$x" \\> "$y" ]`, valid bash
1186
+ * and a syntax error in zsh, which spun for five minutes writing to a stderr
1187
+ * nobody reads.
1188
+ *
1189
+ * What made that expensive was not the typo. It is that SILENCE WAS THE SUCCESS
1190
+ * SIGNAL: from outside, a watcher that can never fire and one patiently working
1191
+ * look exactly alike. So the comparison lives here, in one place, where a test
1192
+ * can hand it a store that gains a pass mid-flight and watch it return.
1193
+ * ========================================================================== */
1194
+
1195
+ const WAIT_POLL_MS = 400
1196
+
1197
+ // How often a running wait says it is still running. Five minutes, against a
1198
+ // 400ms poll: this is not part of the poll at all, it is proof of life for
1199
+ // whatever is supervising the process.
1200
+ const WAIT_HEARTBEAT_MS = 5 * 60 * 1000
1201
+
1202
+ /**
1203
+ * Block until exactly one pass arrives inside a window. Impure only in that it
1204
+ * reads the store and sleeps; it writes nothing and claims nothing.
1205
+ *
1206
+ * Four outcomes, and they are distinct because the caller acts differently on
1207
+ * each:
1208
+ *
1209
+ * - `{ state: 'arrived', code }` — one pass, inside the window. Claim it.
1210
+ * - `{ state: 'ambiguous', count }` — more than one. NAMES NO CODE, because
1211
+ * choosing between two is exactly the guess `claimPending` refuses to make.
1212
+ * - `{ state: 'timeout' }` — only when a timeout was asked for.
1213
+ * - `{ state: 'unusable' }` — the window could not be parsed, so there is
1214
+ * nothing to wait inside. Returns AT ONCE rather than blocking forever on a
1215
+ * comparison that can never be satisfied — which is the failure this whole
1216
+ * function exists to stop.
1217
+ *
1218
+ * NO TIMEOUT UNLESS ASKED. `timeoutMs` omitted means wait as long as the
1219
+ * process lives, and that is the default the skills use: any fixed number is a
1220
+ * guess about how long someone reads, and a reader who walks away from a diff
1221
+ * is the normal case rather than the edge one. An hour was picked once and a
1222
+ * lunch break beat it.
1223
+ *
1224
+ * IT SAYS IT IS STILL RUNNING, on `onHeartbeat`. A backgrounded wait produced
1225
+ * no output between starting and finding a pass, and over a long idle it was
1226
+ * killed — three sessions lost their wait over one lunch break. Whether the
1227
+ * trigger is process silence or session idleness was never established, so the
1228
+ * cheap half is done here: the process stops being silent. The other half is
1229
+ * the caller re-arming, which covers it either way.
1230
+ *
1231
+ * The cadence is INDEPENDENT of the poll. Polling stays at 400ms because that
1232
+ * is how fast a press should be noticed; the heartbeat is minutes apart because
1233
+ * it is addressed to a supervisor, not to a reader.
1234
+ *
1235
+ * WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
1236
+ * store as a reason to return. None of those is a pass — they are the ordinary
1237
+ * state of a review nobody has answered yet — and a wait that ended on one
1238
+ * would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
1239
+ * rule 3 has a test for each).
1240
+ */
1241
+ async function waitForPass(
1242
+ readStore,
1243
+ since,
1244
+ {
1245
+ timeoutMs = null,
1246
+ pollMs = WAIT_POLL_MS,
1247
+ sleep,
1248
+ onHeartbeat = null,
1249
+ heartbeatMs = WAIT_HEARTBEAT_MS,
1250
+ now = () => Date.now(),
1251
+ } = {},
1252
+ ) {
1253
+ if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
1254
+ const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
1255
+ const startedAt = now()
1256
+ const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? startedAt + timeoutMs : null
1257
+ // Counted from the start rather than scheduled, so a slow poll cannot make
1258
+ // the heartbeat drift and a fast one cannot make it fire twice.
1259
+ let beats = 0
1260
+
1261
+ for (;;) {
1262
+ // A store that will not parse is not an empty one, and it is not a pass
1263
+ // either — keep waiting rather than reading someone's unreadable passes as
1264
+ // an answer. `readPending` already reports `corrupt` rather than throwing.
1265
+ const read = readStore()
1266
+ if (read && !read.corrupt) {
1267
+ const window = passesSince(read.pending, since)
1268
+ if (window.usable) {
1269
+ if (window.codes.length === 1) return { state: 'arrived', code: window.codes[0] }
1270
+ if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
1271
+ }
1272
+ }
1273
+ if (deadline !== null && now() >= deadline) return { state: 'timeout' }
1274
+
1275
+ // SAID ON THE WAY ROUND, never on the way out. A line printed when the wait
1276
+ // ends would only ever appear for the waits that already worked, which is
1277
+ // the half that never needed proof.
1278
+ if (onHeartbeat && heartbeatMs > 0) {
1279
+ const elapsed = now() - startedAt
1280
+ const due = Math.floor(elapsed / heartbeatMs)
1281
+ if (due > beats) {
1282
+ beats = due
1283
+ onHeartbeat({ elapsedMs: elapsed, since })
1284
+ }
1285
+ }
1286
+
1287
+ // Never overshoot the deadline by a whole poll interval.
1288
+ await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - now())))
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * Every waiting pass, across every spec. Reads; writes, claims and refuses
1294
+ * nothing.
1295
+ *
1296
+ * A wait that never fired, a session cleared, a terminal closed overnight —
1297
+ * none of those is recoverable by any watcher, however good. This is what finds
1298
+ * the pass afterwards, so recovery stops depending on the operator happening to
1299
+ * suspect something.
1300
+ *
1301
+ * IT READS THE SIDECAR DIRECTORY, NEVER THE PROVISIONED LIST, and that is the
1302
+ * load-bearing decision rather than an implementation detail. `specEnvStatus`
1303
+ * walks specs that have a **worktree**; the ten passes that motivated this all
1304
+ * belonged to specs that had been completed and torn down. Scoping the scan to
1305
+ * provisioned specs would therefore make it blind to precisely the case that
1306
+ * produced it. A `<spec>.pending.json` is here because a pass was received —
1307
+ * a positive signal (`.claude/rules/negative-checks.md` rule 1), and one that
1308
+ * outlives the spec's worktree, its branch and its folder.
1309
+ *
1310
+ * WHAT WOULD FOOL THIS: a store that will not parse. It holds someone's passes
1311
+ * and reading it as empty would report "nothing waiting", which is the one
1312
+ * answer that is certainly wrong — so it comes back named in `unreadable`
1313
+ * rather than counted as zero (rule 4).
1314
+ *
1315
+ * Oldest first and stable, ties broken on the code, exactly as
1316
+ * `describePending` orders one spec's own.
1317
+ */
1318
+ function waitingPasses(reviewsDirPath) {
1319
+ let entries
1320
+ try {
1321
+ entries = fs.readdirSync(reviewsDirPath)
1322
+ } catch {
1323
+ // No directory means no review has ever been rendered here. That is the
1324
+ // ordinary state of a fresh repo, not a failure to report.
1325
+ return { passes: [], unreadable: [] }
1326
+ }
1327
+
1328
+ const passes = []
1329
+ const unreadable = []
1330
+ for (const name of entries.sort()) {
1331
+ if (!name.endsWith(PENDING_SUFFIX)) continue
1332
+ const folder = name.slice(0, -PENDING_SUFFIX.length)
1333
+ // The engine's own reader, so "corrupt" means here what it means everywhere.
1334
+ const read = readPending(path.join(reviewsDirPath, `${folder}.html`), folder)
1335
+ if (read.corrupt) {
1336
+ unreadable.push(folder)
1337
+ continue
1338
+ }
1339
+ for (const pass of read.pending.passes || []) {
1340
+ if (pass && pass.code) {
1341
+ passes.push({
1342
+ spec: folder,
1343
+ code: pass.code,
1344
+ verdict: readVerdict(pass.blob && pass.blob.verdict),
1345
+ action: (pass.blob && pass.blob.action) || null,
1346
+ at: pass.at || null,
1347
+ })
1348
+ }
1349
+ }
1350
+ }
1351
+ passes.sort((a, b) => {
1352
+ const at = String(a.at || '').localeCompare(String(b.at || ''))
1353
+ return at !== 0 ? at : a.code.localeCompare(b.code)
1354
+ })
1355
+ return { passes, unreadable }
1356
+ }
1357
+
785
1358
  /* ==========================================================================
786
1359
  * The gate — a standing obligation to review, not a message in flight
787
1360
  *
@@ -799,6 +1372,39 @@ function passesSince(pending, since) {
799
1372
 
800
1373
  const GATE_VERSION = 1
801
1374
 
1375
+ /**
1376
+ * The reason a BYPASS records, and it is deliberately a fixed string.
1377
+ *
1378
+ * A skip normally carries what the operator typed. This one is chosen from a
1379
+ * picker with nothing typed, so the record has to say that for itself — and a
1380
+ * string nobody would write by hand is what keeps a tapped bypass tellable from
1381
+ * a considered one when both appear in the same log.
1382
+ *
1383
+ * It stays `by: 'skip'` rather than minting a third `DISARMED_BY` verb: the
1384
+ * sidecar's vocabulary is versioned, and a distinct reason already buys the only
1385
+ * thing a third verb would have.
1386
+ */
1387
+ const GATE_BYPASS_REASON = 'none: chose to commit without reading the diff'
1388
+
1389
+ /**
1390
+ * The way out of an armed gate, declared beside the refusal rather than left
1391
+ * for a caller to reconstruct.
1392
+ *
1393
+ * A BYPASS, NOT A SATISFY: the phase still owes a verdict and nobody has read
1394
+ * it, so this steps past a guard that is still unsatisfied — which is exactly
1395
+ * why it is recorded and a `satisfy` is not.
1396
+ *
1397
+ * WHAT THIS IS NOT: a `--force`. One was asked for and rejected, because a
1398
+ * reasonless lift is indistinguishable from nobody having looked. The exits
1399
+ * this gate has are unchanged — a committing verdict, or a recorded skip — and
1400
+ * this is the second of those two at an address a person will actually reach.
1401
+ */
1402
+ const GATE_BYPASS_OFFER = Object.freeze({
1403
+ kind: 'bypass',
1404
+ label: 'Commit without reading the diff — recorded as such',
1405
+ command: `skitterspec spec-env review skip "${GATE_BYPASS_REASON}"`,
1406
+ })
1407
+
802
1408
  // How a disarm happened. `verdict` is a review that reached a committing
803
1409
  // conclusion; `skip` is the operator saying, on the record, that they are
804
1410
  // moving on without one.
@@ -809,7 +1415,7 @@ function reviewGatePath(outPath) {
809
1415
  }
810
1416
 
811
1417
  function emptyGate(specFolder) {
812
- return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, log: [] }
1418
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, log: [] }
813
1419
  }
814
1420
 
815
1421
  /**
@@ -837,6 +1443,10 @@ function readGate(outPath, specFolder) {
837
1443
  armed: parsed.armed === true,
838
1444
  armedAt: parsed.armedAt || null,
839
1445
  phase: parsed.phase === undefined ? null : parsed.phase,
1446
+ // A sidecar written before this field existed reads as never-offered,
1447
+ // which is the correct and harmless default. These files are gitignored,
1448
+ // so there is no fleet to migrate and `GATE_VERSION` does not move.
1449
+ offeredAt: parsed.offeredAt || null,
840
1450
  log: Array.isArray(parsed.log) ? parsed.log : [],
841
1451
  },
842
1452
  corrupt: false,
@@ -854,6 +1464,64 @@ function writeGate(outPath, gate) {
854
1464
  return p
855
1465
  }
856
1466
 
1467
+ /* ==========================================================================
1468
+ * The render record — what the caller DECLARED, for the re-render to read
1469
+ * ========================================================================== */
1470
+
1471
+ // Same stem as the other sidecars, so one spec's set still reads as one set.
1472
+ function renderRecordPath(outPath) {
1473
+ return outPath.replace(/\.html$/, '') + '.render.json'
1474
+ }
1475
+
1476
+ /**
1477
+ * The button set the last CLI render of this spec declared, or null.
1478
+ *
1479
+ * WHY THIS EXISTS AT ALL. `--buttons` is declared by the caller — `BUTTON_SETS`
1480
+ * above says why it is never derived — and the review daemon is not that
1481
+ * caller. It re-renders a spec's page per request from the tree, so without a
1482
+ * record it can only guess, and what it guessed was the default: every
1483
+ * `/no-spec` page served over http offered `Commit & Start`.
1484
+ *
1485
+ * WHAT WOULD FOOL A CONSUMER THAT TRUSTED THIS OUTRIGHT: it is a file on disk
1486
+ * and the tree moves underneath it. A spec rendered as `--docs` and then put in
1487
+ * flight has a day-old record saying `authoring` and a worktree saying
1488
+ * otherwise. So `buttonsForView` in `serve.js` takes only NARROWING words from
1489
+ * here and reads the family off the tree — this function's job is to report
1490
+ * what was written, not to decide whether it still holds.
1491
+ *
1492
+ * Three states, like `readNotes` and `readGate`: absent is the ordinary case,
1493
+ * and corrupt is reported rather than hidden so neither reads as a declaration.
1494
+ */
1495
+ function readRenderRecord(outPath, specFolder) {
1496
+ let raw
1497
+ try {
1498
+ raw = fs.readFileSync(renderRecordPath(outPath), 'utf8')
1499
+ } catch {
1500
+ // Absent is ordinary: most pages are opened off the index without the CLI
1501
+ // ever having rendered that spec.
1502
+ return { buttons: null, corrupt: false, present: false }
1503
+ }
1504
+ try {
1505
+ const parsed = JSON.parse(raw)
1506
+ const buttons = typeof parsed.buttons === 'string' && parsed.buttons ? parsed.buttons : null
1507
+ // The folder is checked, not trusted: a record found under this spec's stem
1508
+ // that names another spec is not this spec's declaration.
1509
+ if (parsed.spec && specFolder && parsed.spec !== specFolder) {
1510
+ return { buttons: null, corrupt: false, present: true }
1511
+ }
1512
+ return { buttons, corrupt: false, present: true }
1513
+ } catch {
1514
+ return { buttons: null, corrupt: true, present: true }
1515
+ }
1516
+ }
1517
+
1518
+ function writeRenderRecord(outPath, record) {
1519
+ const p = renderRecordPath(outPath)
1520
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1521
+ fs.writeFileSync(p, JSON.stringify(record, null, 2) + '\n')
1522
+ return p
1523
+ }
1524
+
857
1525
  /**
858
1526
  * Arm the gate. Pure.
859
1527
  *
@@ -863,8 +1531,32 @@ function writeGate(outPath, gate) {
863
1531
  * for a DIFFERENT phase is re-armed, because that is a new obligation.
864
1532
  */
865
1533
  function armGate(gate, { at, phase = null }) {
1534
+ // Idempotent within a phase — a re-render must not restart the clock, and it
1535
+ // must not hand back an offer this arming has already spent.
866
1536
  if (gate.armed && gate.phase === phase) return gate
867
- return { ...gate, armed: true, armedAt: at, phase }
1537
+ // A NEW ARMING GETS A NEW OFFER. Each phase that ends is its own obligation,
1538
+ // so the once-only rule is scoped to the arming rather than to the file.
1539
+ return { ...gate, armed: true, armedAt: at, phase, offeredAt: null }
1540
+ }
1541
+
1542
+ /**
1543
+ * Record that the bypass was offered for this arming. Pure.
1544
+ *
1545
+ * SPENT BY BEING MADE, not by being taken. A reader who declines still saw it,
1546
+ * and re-offering on the next refused commit is how a bypass becomes the default
1547
+ * rather than a deliberate step.
1548
+ *
1549
+ * NOT DONE AS A SIDE EFFECT OF READING THE GATE, deliberately. `/spec-next`
1550
+ * asks `review gate --json` on every single run, so a read that spent the
1551
+ * offer would burn it with no picker ever raised — and the operator would never
1552
+ * be offered the thing this exists to offer them.
1553
+ *
1554
+ * Marking an unarmed gate changes nothing: there is no obligation, so there is
1555
+ * nothing to have offered a way out of.
1556
+ */
1557
+ function markGateOffered(gate, { at }) {
1558
+ if (!gate.armed || gate.offeredAt) return { gate, marked: false }
1559
+ return { gate: { ...gate, offeredAt: at }, marked: true }
868
1560
  }
869
1561
 
870
1562
  /**
@@ -935,7 +1627,15 @@ function gateState({ gate, corrupt, present, required }) {
935
1627
  }
936
1628
  }
937
1629
  if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
938
- return { state: 'armed', reason: 'a phase is awaiting a verdict', gate }
1630
+ // The refusal is unchanged by the offer having been spent — what is withheld
1631
+ // is the way out, never the guard. A second refused commit still refuses, and
1632
+ // still names its exits in the text; the operator simply is not asked again.
1633
+ return {
1634
+ state: 'armed',
1635
+ reason: 'a phase is awaiting a verdict',
1636
+ gate,
1637
+ ...(gate.offeredAt ? {} : { offer: GATE_BYPASS_OFFER }),
1638
+ }
939
1639
  }
940
1640
 
941
1641
  /**
@@ -1005,7 +1705,20 @@ function validateNotesBlob(blob, specFolder) {
1005
1705
  }
1006
1706
  verdict = named
1007
1707
  }
1008
- return { accepted, unaccepted, comments, verdict }
1708
+ // AN ACTION, refused by name for the same reason a misspelt verdict is: a
1709
+ // dropped `action: "live-onn"` would read as a pass carrying no instruction
1710
+ // at all, reported as if the press had been honoured.
1711
+ let action = null
1712
+ if (blob.action !== undefined && blob.action !== null) {
1713
+ if (typeof blob.action !== 'string' || !ACTIONS.includes(blob.action)) {
1714
+ fail(`action ${JSON.stringify(blob.action)} is not one of ${ACTIONS.join(', ')}`)
1715
+ }
1716
+ action = blob.action
1717
+ }
1718
+ if (action && verdict) {
1719
+ fail(`carries both a verdict (${verdict}) and an action (${action}) — send one`)
1720
+ }
1721
+ return { accepted, unaccepted, comments, verdict, action }
1009
1722
  }
1010
1723
 
1011
1724
  /**
@@ -1032,7 +1745,15 @@ function validateNotesBlob(blob, specFolder) {
1032
1745
  * failure we can afford.
1033
1746
  */
1034
1747
  function judgeVerdict(verdict, notes) {
1035
- const sent = readVerdict(verdict) || null
1748
+ const read = readVerdict(verdict) || null
1749
+ // A WORD THIS ENGINE DOES NOT KNOW IS NOT HONOURED AS ONE. Every door into
1750
+ // here validates first — `validateNotesBlob` refuses an unknown verdict by
1751
+ // name and refuses an action in the verdict slot, and `--verdict` checks the
1752
+ // list — so nothing reaches this today. It used to pass such a word straight
1753
+ // through with `honoured: true`, which routes cannot-tell to the branch that
1754
+ // acts (`.claude/rules/negative-checks.md` rule 4 inverted). Closing it costs
1755
+ // nothing and means a fifth door added later cannot reopen it.
1756
+ const sent = read && VERDICTS.includes(read) ? read : null
1036
1757
  const asked = sent || DEFAULT_VERDICT
1037
1758
  const open = (notes.comments || []).filter((c) => !c.resolved)
1038
1759
  const openFiles = [...new Set(open.map((c) => c.file))]
@@ -1060,9 +1781,18 @@ function judgeVerdict(verdict, notes) {
1060
1781
  * commit something nobody read. What is kept is the account of what was
1061
1782
  * decided, when, and eventually what it produced.
1062
1783
  */
1063
- function appendDecision(notes, { verdict, at, note = null }) {
1784
+ function appendDecision(notes, { verdict, at, note = null, code = null }) {
1064
1785
  const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
1065
- decisions.push({ verdict, at, note: note === undefined ? null : note })
1786
+ // `code` NAMES THE PASS this decision came out of, and it is the only present
1787
+ // thing anyone can assert to conclude that a pass was picked up. Four
1788
+ // different things make a code vanish from the holding area and only one of
1789
+ // them is a claim — `--drop` is another, a moved store and a mistyped folder
1790
+ // are two more — so `passState` reads this, and never reads an absence.
1791
+ //
1792
+ // `null` for every other path in: a pasted blob and a `--verdict` word carry
1793
+ // no code, and inventing one would put a pass in the record that never
1794
+ // existed.
1795
+ decisions.push({ verdict, at, note: note === undefined ? null : note, code: code || null })
1066
1796
  return { ...notes, updatedAt: at, decisions }
1067
1797
  }
1068
1798
 
@@ -1360,6 +2090,7 @@ function renderReviewBlock(review) {
1360
2090
  if (review.summary) {
1361
2091
  parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
1362
2092
  }
2093
+ parts.push(...renderReviewerStrip(review.reviewers))
1363
2094
  const checks = Array.isArray(review.checks) ? review.checks : []
1364
2095
  if (checks.length) {
1365
2096
  parts.push('<ul class="checks">')
@@ -1368,16 +2099,31 @@ function renderReviewBlock(review) {
1368
2099
  // reviewer's note because it was tagged oddly is worse than showing it
1369
2100
  // under a neutral heading.
1370
2101
  const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
1371
- const file = c.file ? `<span class="check-file">${escapeHtml(c.file)}</span>` : ''
2102
+ // `file:line` where a line is known, and the whole thing is a button so a
2103
+ // finding can be read against the code it is about. A check with no line
2104
+ // renders exactly as it always did — the written review supplies none, and
2105
+ // its output must not change because machine findings now exist.
2106
+ const hasLine = Number.isInteger(c.line) && c.line > 0
2107
+ const label = c.file ? escapeHtml(c.file) + (hasLine ? `:${c.line}` : '') : ''
2108
+ const file = c.file
2109
+ ? `<button type="button" class="check-file" data-goto="${escapeHtml(c.file)}"` +
2110
+ `${hasLine ? ` data-goto-line="${c.line}"` : ''}>${label}</button>`
2111
+ : ''
2112
+ // WHO SAID IT. Absent for a written review, which is what it has always
2113
+ // been; present the moment two authors can appear on one page, because a
2114
+ // reader weighing a finding needs to know whether a person read the code
2115
+ // or a tool matched a pattern.
2116
+ const src = c.source ? `<span class="check-src">${escapeHtml(c.source)}</span>` : ''
1372
2117
  // The id is positional and therefore stable for THIS render, which is all
1373
2118
  // a reply needs: it travels back in the same blob the page was built from.
1374
2119
  // `data-file` is what lets an answer land on the file the check is about.
1375
2120
  const id = `k${i}`
1376
2121
  const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
2122
+ const lineAttr = hasLine ? ` data-line="${c.line}"` : ''
1377
2123
  parts.push(
1378
- `<li class="check ${level}" data-check="${id}"${fileAttr}>` +
2124
+ `<li class="check ${level}" data-check="${id}"${fileAttr}${lineAttr}>` +
1379
2125
  `<span class="check-level">${level}</span>` +
1380
- `${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
2126
+ `${src}${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
1381
2127
  )
1382
2128
  })
1383
2129
  parts.push('</ul>')
@@ -1386,6 +2132,65 @@ function renderReviewBlock(review) {
1386
2132
  return parts.join('\n')
1387
2133
  }
1388
2134
 
2135
+ /**
2136
+ * One line per configured reviewer, saying what its run ended as.
2137
+ *
2138
+ * PRESENT EVEN WHEN EVERY REVIEWER FOUND NOTHING, which is the whole point and
2139
+ * the one place `.claude/rules/negative-checks.md` inverts here. Silence is
2140
+ * normally the safe branch; on this page it is not, because a reviewer that was
2141
+ * rate-limited renders identically to one that read the diff and approved of it
2142
+ * — and the reader is about to decide whether to commit. So the strip reports
2143
+ * the RUN, which is a positive signal, rather than accusing the code.
2144
+ *
2145
+ * Absent entirely when no reviewer is configured: every project that exists
2146
+ * today is in that state, and a strip saying "no reviewers" would be an absence
2147
+ * reported at everyone.
2148
+ */
2149
+ function renderReviewerStrip(outcomes) {
2150
+ const list = Array.isArray(outcomes) ? outcomes : []
2151
+ if (!list.length) return []
2152
+ const parts = ['<ul class="reviewers">']
2153
+ for (const o of list) {
2154
+ const state = typeof o.state === 'string' ? o.state : 'failed'
2155
+ parts.push(
2156
+ `<li class="reviewer ${escapeHtml(state)}">` +
2157
+ `<span class="reviewer-name">${escapeHtml(o.name || 'reviewer')}</span>` +
2158
+ `<span class="reviewer-said">${escapeHtml(reviewerSaid(o))}</span></li>`,
2159
+ )
2160
+ }
2161
+ parts.push('</ul>')
2162
+ return parts
2163
+ }
2164
+
2165
+ /**
2166
+ * What one reviewer's outcome reads as. Pure.
2167
+ *
2168
+ * A STATE THIS BUILD DOES NOT KNOW IS SHOWN, not dropped — the same rule the
2169
+ * check level follows. An unrecognised state means a newer engine wrote the
2170
+ * cache, and losing the line entirely would read as a reviewer that was never
2171
+ * configured.
2172
+ */
2173
+ function reviewerSaid(o) {
2174
+ const n = Number.isInteger(o.count) ? o.count : 0
2175
+ const detail = o.detail ? ` — ${o.detail}` : ''
2176
+ switch (o.state) {
2177
+ case 'findings':
2178
+ return `${n} finding${n === 1 ? '' : 's'}${detail}`
2179
+ case 'cached':
2180
+ return n ? `${n} finding${n === 1 ? '' : 's'} · cached` : 'clean · cached'
2181
+ case 'clean':
2182
+ return 'clean'
2183
+ case 'missing':
2184
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2185
+ case 'timeout':
2186
+ return 'did not run — timed out'
2187
+ case 'failed':
2188
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2189
+ default:
2190
+ return `${o.state}${detail}`
2191
+ }
2192
+ }
2193
+
1389
2194
  /**
1390
2195
  * Where a spec's published URL is remembered, and what it currently says.
1391
2196
  *
@@ -1447,8 +2252,9 @@ function reviewServerNotice({ running, othersServed }) {
1447
2252
  ]
1448
2253
  }
1449
2254
 
1450
- // The publish-ready copy, beside the page it came from. Same stem, so the three
1451
- // sidecars (`.notes.json`, `.url`, `.publish.html`) all read as one spec's set.
2255
+ // The publish-ready copy, beside the page it came from. Same stem, so every
2256
+ // sidecar (`.notes.json`, `.url`, `.publish.html`, `.render.json`) reads as one
2257
+ // spec's set.
1452
2258
  function reviewPublishPath(outPath) {
1453
2259
  return outPath.replace(/\.html$/, '') + '.publish.html'
1454
2260
  }
@@ -1501,6 +2307,11 @@ module.exports = {
1501
2307
  WHOLE_FILE_CONTEXT,
1502
2308
  NOTES_VERSION,
1503
2309
  VERDICTS,
2310
+ ACTIONS,
2311
+ tierAction,
2312
+ surfacesFor,
2313
+ reviewTierStack,
2314
+ reviewTierLine,
1504
2315
  COMMITTING,
1505
2316
  BUTTON_SETS,
1506
2317
  DEFAULT_BUTTON_SET,
@@ -1518,6 +2329,7 @@ module.exports = {
1518
2329
  appendDecision,
1519
2330
  annotateLastDecision,
1520
2331
  reviewPendingPath,
2332
+ PENDING_SUFFIX,
1521
2333
  emptyPending,
1522
2334
  readPending,
1523
2335
  writePending,
@@ -1525,6 +2337,11 @@ module.exports = {
1525
2337
  addPending,
1526
2338
  claimPending,
1527
2339
  passesSince,
2340
+ waitingPasses,
2341
+ waitForPass,
2342
+ WAIT_HEARTBEAT_MS,
2343
+ WAIT_POLL_MS,
2344
+ passState,
1528
2345
  describePending,
1529
2346
  pendingAge,
1530
2347
  PENDING_CODE_LENGTH,
@@ -1534,9 +2351,15 @@ module.exports = {
1534
2351
  emptyGate,
1535
2352
  readGate,
1536
2353
  writeGate,
2354
+ renderRecordPath,
2355
+ readRenderRecord,
2356
+ writeRenderRecord,
1537
2357
  armGate,
1538
2358
  disarmGate,
1539
2359
  gateState,
2360
+ markGateOffered,
2361
+ GATE_BYPASS_OFFER,
2362
+ GATE_BYPASS_REASON,
1540
2363
  gateForPage,
1541
2364
  mergeNotes,
1542
2365
  applyResolutions,
@@ -1557,6 +2380,8 @@ module.exports = {
1557
2380
  escapeIsland,
1558
2381
  escapeHtml,
1559
2382
  renderReviewBlock,
2383
+ renderReviewerStrip,
2384
+ reviewerSaid,
1560
2385
  reviewUrlPath,
1561
2386
  reviewPublishPath,
1562
2387
  readReviewUrl,