@skitterbyte/skitterspec-linear 14.0.0 → 17.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.
Files changed (49) hide show
  1. package/MIGRATION.md +304 -4
  2. package/README.md +34 -1
  3. package/assets/claude-md-section.md +29 -18
  4. package/assets/commands/spec-remote-review.md +22 -0
  5. package/assets/core/SETUP.md +10 -6
  6. package/assets/core/env.config.json.example +4 -2
  7. package/assets/core/env.config.md +103 -25
  8. package/assets/core/linear.config.json.example +2 -1
  9. package/assets/core/linear.config.md +49 -22
  10. package/assets/review/page.html +1101 -108
  11. package/assets/rules/spec-planning.md +39 -7
  12. package/assets/rules/spec-reports.md +210 -31
  13. package/assets/skills/spec/SKILL.md +161 -4
  14. package/assets/skills/spec-bug/SKILL.md +102 -49
  15. package/assets/skills/spec-cancel/SKILL.md +2 -2
  16. package/assets/skills/spec-claim/SKILL.md +12 -4
  17. package/assets/skills/spec-complete/SKILL.md +2 -2
  18. package/assets/skills/spec-diff/SKILL.md +183 -39
  19. package/assets/skills/spec-hotfix/SKILL.md +96 -49
  20. package/assets/skills/spec-init/SKILL.md +18 -6
  21. package/assets/skills/spec-linear-setup/SKILL.md +19 -11
  22. package/assets/skills/spec-next/SKILL.md +147 -62
  23. package/assets/skills/spec-push/SKILL.md +45 -0
  24. package/assets/skills/spec-review/SKILL.md +89 -2
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +26 -3
  27. package/assets/skills/spec-status/SKILL.md +20 -6
  28. package/assets/skills/spec-sync/SKILL.md +1 -0
  29. package/package.json +2 -2
  30. package/src/cli.js +940 -116
  31. package/src/env/classify.js +87 -2
  32. package/src/env/config.js +214 -17
  33. package/src/env/hooks.js +49 -9
  34. package/src/env/live.js +94 -0
  35. package/src/env/resolve.js +36 -2
  36. package/src/env/review.js +581 -21
  37. package/src/env/serve.js +298 -19
  38. package/src/env/supervise.js +8 -1
  39. package/src/init.js +88 -13
  40. package/src/vendor/linear/api.js +111 -2
  41. package/src/vendor/linear/cli-sync.js +661 -11
  42. package/src/vendor/linear/config.js +41 -13
  43. package/src/vendor/linear/doctor.js +6 -5
  44. package/src/vendor/sync-core/index.js +11 -3
  45. package/src/vendor/sync-core/src/compare.js +65 -0
  46. package/src/vendor/sync-core/src/normalize.js +26 -0
  47. package/src/vendor/sync-core/src/retarget.js +1 -1
  48. package/src/vendor/sync-core/src/task-block.js +2 -2
  49. /package/assets/hooks/{review-gate.js → review-gate.cjs} +0 -0
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.
@@ -230,18 +257,48 @@ function resolveReader(config, env = {}) {
230
257
  * `fellBack` records that `branch` was reached because the working tree was
231
258
  * clean, not because the caller asked for it — see the fallback in
232
259
  * `specEnvReview` (`cli.js`).
260
+ *
261
+ * `buttons` is the button set the page renders — see `BUTTON_SETS`. It is the
262
+ * caller's declaration about the work, not a reading of the gate.
233
263
  */
234
- function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false }) {
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 })
289
+
235
290
  const files = []
236
291
  for (const f of trackedFiles(git, ref)) {
292
+ if (!keep(f)) continue
237
293
  const { patch, whole } = patchFor(git, ref, f, false)
238
294
  const { additions, deletions, binary } = numstatFor(git, ref, f, false)
239
- 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 })
240
296
  }
241
297
  for (const f of untrackedFiles(git)) {
298
+ if (!keep(f)) continue
242
299
  const { patch, whole } = patchFor(git, ref, f, true)
243
300
  const { additions, deletions, binary } = numstatFor(git, ref, f, true)
244
- 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 })
245
302
  }
246
303
 
247
304
  // Content hashes and the stored review state, folded on before the totals so
@@ -258,7 +315,7 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
258
315
  // Every bucket, because a page is rendered for specs in `in-progress/` and for
259
316
  // finished ones in `complete/` — and the finished one is the case this exists
260
317
  // for. `null` when it cannot tell, and the page leaves its button alone.
261
- const specDir = spec.worktreePath ? findSpecDirIn(spec.worktreePath, spec.folder) : null
318
+ const specDir = root ? findSpecDirIn(root, spec.folder) : null
262
319
  const phases = specDir ? readPhases(specDir) : null
263
320
  // The PR description this page never had: why the change exists, what it
264
321
  // touches, and what this phase set out to do.
@@ -277,7 +334,11 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
277
334
  spec: spec.folder,
278
335
  title: spec.folder,
279
336
  branch: spec.branch,
280
- 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,
281
342
  mode,
282
343
  base,
283
344
  ref,
@@ -301,6 +362,31 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
301
362
  // Same rule again: a gate that was never armed and never skipped adds no
302
363
  // key at all.
303
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 } : {}),
386
+ // THE DEFAULT ADDS NO KEY, so a caller that did not ask for a button set —
387
+ // and a caller that asked for the default by name — renders the payload it
388
+ // rendered before this existed. Opting in is the only thing that shows.
389
+ ...(buttons && buttons !== DEFAULT_BUTTON_SET ? { buttons } : {}),
304
390
  // WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
305
391
  // happen per request — so a page rendered by a stale process looks entirely
306
392
  // right: the counts move, `generatedAt` moves, the diff is correct. Only the
@@ -394,20 +480,255 @@ const NOTES_VERSION = 1
394
480
  * THE VERDICT NAMES THE ACTION. It was `approve` once, and an approval that
395
481
  * only recorded itself is the one thing on a review page that does not describe
396
482
  * what happens — a review is the guard in front of an action, so the word is
397
- * 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.
398
492
  *
399
493
  * `discuss` is the default because it is the behaviour that existed before any
400
494
  * verdict did. So a blob from an older page, or one a reader sent without
401
495
  * choosing, keeps doing exactly what it always did.
496
+ *
497
+ * `continue` is the mid-run verdict: *I have read it, carry on*. It names an
498
+ * action, which is what separates it from the `none` verdict that was removed —
499
+ * `none` recorded itself and did nothing, while this one resumes the run. What
500
+ * it does NOT do is commit, so it is deliberately absent from `COMMITTING`
501
+ * below and is therefore structurally incapable of clearing an armed gate: a
502
+ * phase that ended still owes a committing verdict or a recorded skip.
402
503
  */
403
- const VERDICTS = ['commit', 'commit-continue', 'changes', 'discuss']
504
+ const VERDICTS = ['commit', 'commit-continue', 'commit-start', 'continue', 'changes', 'discuss']
404
505
  const DEFAULT_VERDICT = 'discuss'
405
506
 
507
+ /**
508
+ * Actions the page can send INSTEAD of a verdict.
509
+ *
510
+ * AN ACTION IS NOT A VERDICT, and keeping the two vocabularies apart is the
511
+ * whole point rather than tidiness. A verdict is a conclusion about the work,
512
+ * consumed once by the thing it asked for. An action changes what is *running*
513
+ * — or which tiers are permitted — and then puts the reader back on the same
514
+ * page with the same options, so a run answering one has concluded nothing.
515
+ *
516
+ * THERE IS NO `live-off`, and its absence is the decision. Putting *this* change
517
+ * live is about the diff on screen; handing the whole instance back to `main` is
518
+ * a workspace decision with nothing to do with this review, and a control for it
519
+ * on a review page invites the reader to make it while thinking about something
520
+ * else. The `live:` line names `/spec-live main` instead, which is what a person
521
+ * types.
522
+ *
523
+ * Three consequences follow from that, and all three are structural rather than
524
+ * remembered:
525
+ *
526
+ * - It is absent from `VERDICTS`, so nothing that routes on a verdict can see
527
+ * it.
528
+ * - It is absent from `COMMITTING`, so it is **incapable** of clearing an armed
529
+ * gate: a phase that ended still owes a verdict after the reader has looked
530
+ * at it running. The commit that a `live-on` performs first is the mechanical
531
+ * precondition for `live take` (which refuses a dirty worktree), not the
532
+ * reader's answer.
533
+ * - A blob may carry a verdict or an action, never both. Two answers in one
534
+ * pass would need a rule about which wins, and any such rule would sometimes
535
+ * act on the half the reader did not mean.
536
+ *
537
+ * `allow-network`/`allow-remote` turn a review TIER on, and they exist here
538
+ * because the page can now act — which is the premise
539
+ * `feat-three-review-links` decision 5 rejected a page toggle on, having
540
+ * recorded it as open to reopening. Two things replace the limit it rested on.
541
+ * **Only a reader who can already reach the page can press one**, so widening
542
+ * loopback→network can only be pressed from the machine itself, where the
543
+ * command was available anyway, and the serve token still decides who reaches
544
+ * the page at all. And `allow-remote` **permits publishing without
545
+ * publishing** — the permanent claude.ai page still takes an explicit ask.
546
+ *
547
+ * There is no disable direction, and `tierAction` is where that is enforced:
548
+ * turning `network` off from a page reached over the network kills the page
549
+ * doing the turning.
550
+ */
551
+ const ACTIONS = ['live-on', 'allow-network', 'allow-remote']
552
+
553
+ /**
554
+ * The action a tier that is OFF offers, or null.
555
+ *
556
+ * ENABLE-ONLY, and that is a decision rather than an omission: turning
557
+ * `network` off from a page you reached over the network kills the page doing
558
+ * the turning, and the reader gains nothing they could not get by typing the
559
+ * command. `local` has no setting at all — it is the machine the page is on.
560
+ */
561
+ function tierAction(tier) {
562
+ if (!tier || !tier.off) return null
563
+ if (tier.tier === 'network') return 'allow-network'
564
+ if (tier.tier === 'remote') return 'allow-remote'
565
+ return null
566
+ }
567
+
568
+ /**
569
+ * What the page shows above the verdicts: where this review can be reached, and
570
+ * whether it is also running. Pure.
571
+ *
572
+ * ONLY WHAT THE READER CAN ACT ON. An `on` tier the reader is already standing
573
+ * on is not information — they are reading it — so the only tier URL carried is
574
+ * `network`'s, which is the one address someone on a laptop wants for their
575
+ * phone. Everything else here is a state plus the one press that changes it.
576
+ *
577
+ * `null` RATHER THAN AN EMPTY BLOCK when there is nothing to say: an
578
+ * `unavailable` live state and no stack means a caller that never asked, and
579
+ * the payload must then be byte-identical to what it was before this key
580
+ * existed.
581
+ */
582
+ function surfacesFor({ live, tiers }) {
583
+ const rows = []
584
+
585
+ // `unavailable` contributes nothing — the cannot-tell state, routed to
586
+ // silence exactly as `liveStateLine` routes it (`env/live.js`).
587
+ if (live && live.state && live.state !== 'unavailable') {
588
+ rows.push({
589
+ kind: 'live',
590
+ state: live.state,
591
+ url: live.url || null,
592
+ reason: live.reason || null,
593
+ // ONE DIRECTION ONLY. `off` offers the press; `on` and `held` offer none —
594
+ // `on` because handing the instance back to `main` is not this review's
595
+ // business, and `held` because the way out is another spec's to take and a
596
+ // button here would either park someone else's work or do nothing.
597
+ action: live.state === 'off' ? 'live-on' : null,
598
+ // The command for the direction the page does not offer, so the reader is
599
+ // never left knowing what they want and not what to type.
600
+ command: live.state === 'on' ? '/spec-live main' : null,
601
+ })
602
+ }
603
+
604
+ for (const t of tiers || []) {
605
+ const action = tierAction(t)
606
+ // ONLY WHAT THE READER CAN ACT ON. An `on` tier contributes nothing: the
607
+ // reader is standing on one of them, and the terminal render is where they
608
+ // got the link — printing it again here would be two places naming one page,
609
+ // which is the split `.claude/rules/spec-reports.md` records as a failure.
610
+ if (!action) continue
611
+ rows.push({
612
+ kind: 'tier',
613
+ tier: t.tier,
614
+ state: 'off',
615
+ url: null,
616
+ // SAID BEFORE THE PRESS, not after it. Unlike the live action, which
617
+ // touches no tracked file, `allow` edits `specs/.core/env.config.json` in
618
+ // the primary checkout — changing behaviour for everyone who pulls and
619
+ // leaving that tree dirty. A setting change of that reach must not land
620
+ // because someone tapped a button labelled only `Allow remote`.
621
+ note: t.note || 'writes env.config.json — committed, and shared',
622
+ // The same thing from a terminal, for the reader who is nowhere near the
623
+ // page. Only `remote` has a command of its own: `network` is ON by
624
+ // default, so turning it off is a rare deliberate act and a slash command
625
+ // per tier is clutter for the one nobody touches.
626
+ command: t.tier === 'remote' ? '/spec-remote-review' : null,
627
+ action,
628
+ })
629
+ }
630
+
631
+ return rows.length ? rows : null
632
+ }
633
+
634
+ /**
635
+ * The three review tiers, in a FIXED ORDER, each either a URL or the reason it
636
+ * is not one. Pure: it takes what the caller already resolved.
637
+ *
638
+ * Fixed order matters more than it looks. A reader who has learnt which line
639
+ * their phone opens should not have to re-read the labels every render, and a
640
+ * stack that reordered itself by availability would make them.
641
+ *
642
+ * WHY A STACK AT ALL, when the rule said exactly one link: the engine cannot
643
+ * know where the reader is sitting, and every attempt to guess failed the
644
+ * moment they moved. `local` and `network` are two doors into ONE room — the
645
+ * page POSTs to `location.pathname`, so both reach the same server and the same
646
+ * pending store, and one wait covers both. `remote` is a second store, which is
647
+ * why it carries the line saying so.
648
+ */
649
+ function reviewTierStack({ served, fileUrl, publishedUrl, config }) {
650
+ const tiers = []
651
+ const loopbackUrl = served && served.loopbackUrl ? served.loopbackUrl : null
652
+
653
+ tiers.push(
654
+ loopbackUrl
655
+ ? { tier: 'local', url: loopbackUrl }
656
+ : { tier: 'local', url: fileUrl, note: 'a file:// page cannot send a verdict' },
657
+ )
658
+
659
+ if (!config.review.allowNetwork) {
660
+ tiers.push({ tier: 'network', off: true, enable: 'skitterspec spec-env review allow network' })
661
+ } else if (served && !served.loopback && served.url) {
662
+ tiers.push({ tier: 'network', url: served.url, alternates: served.alternates || [] })
663
+ } else {
664
+ // Permitted, but there is no address to offer — a machine with no network,
665
+ // or a server that could not take one. Not a problem to report as an error.
666
+ tiers.push({ tier: 'network', unavailable: true, note: 'no network address on this machine' })
667
+ }
668
+
669
+ if (!config.review.allowRemote) {
670
+ // THE COMMAND A PERSON TYPES. `/spec-remote-review` toggles, so the same
671
+ // line works whichever way the tier currently is — and nobody has to
672
+ // reconstruct `spec-env review allow remote` from a render they are reading
673
+ // on a phone.
674
+ tiers.push({ tier: 'remote', off: true, enable: '/spec-remote-review' })
675
+ } else if (publishedUrl) {
676
+ tiers.push({ tier: 'remote', url: publishedUrl, note: 'a verdict here needs /spec-reviewed' })
677
+ } else {
678
+ tiers.push({ tier: 'remote', unavailable: true, note: 'publishing is an ask — nothing published yet' })
679
+ }
680
+
681
+ return tiers
682
+ }
683
+
684
+ // One tier as a line, padded so the labels form a column the eye can run down.
685
+ function reviewTierLine(t) {
686
+ const label = ` ${t.tier}:`.padEnd(11)
687
+ if (t.url) return `${label}${t.url}${t.note ? ` (${t.note})` : ''}`
688
+ if (t.off) return `${label}off — turn on with: ${t.enable}`
689
+ return `${label}— (${t.note})`
690
+ }
691
+
692
+
406
693
  // 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
694
+ // list, so a fifth verdict cannot become a way around the single refusal this
408
695
  // engine makes — adding a committing verdict means adding it here, and the
409
696
  // block follows for free.
410
- const COMMITTING = ['commit', 'commit-continue']
697
+ //
698
+ // `continue` IS DELIBERATELY NOT HERE, and that omission is the whole of
699
+ // decision 2: waiting is what any offer does, while arming asserts an
700
+ // obligation that outlives the turn. A mid-run reader saying "carry on" has
701
+ // answered the offer in front of them and nothing else, so the gate a finished
702
+ // phase armed must survive it untouched.
703
+ const COMMITTING = ['commit', 'commit-continue', 'commit-start']
704
+
705
+ /**
706
+ * Which set of buttons a rendered page shows.
707
+ *
708
+ * DECLARED BY THE CALLER, NEVER DERIVED FROM THE GATE. Deriving it — mid-run
709
+ * iff the gate is unarmed — is tidier and wrong: a project running
710
+ * `review.required: false` never arms at all, so every one of its pages would
711
+ * lose the committing buttons and the reader could never commit from the page.
712
+ * The caller knows whether the work it just rendered is finished; the gate only
713
+ * knows whether this project opted into gating.
714
+ *
715
+ * `committing` is the default, so a caller that says nothing keeps today's page
716
+ * exactly — the key is left off the payload entirely rather than written out as
717
+ * the default, so an unchanged caller renders an unchanged page.
718
+ *
719
+ * `authoring` is the set for a spec that has just been written: its committing
720
+ * pair is `commit-start` and `commit` — put it in flight now, or keep it for
721
+ * later. `commit-continue` is absent because there is no phase in flight to
722
+ * continue, and `continue` is absent because the run has nothing left to resume.
723
+ *
724
+ * `refresh` is the set for a spec that was re-validated rather than written:
725
+ * `commit`, `changes`, `discuss`, and NO start verdict. A refreshed spec may
726
+ * already be in progress, so offering to put it in flight would be wrong for
727
+ * half this set's inputs — and wrong in the expensive direction, since it would
728
+ * offer to provision a worktree for a spec that already has one.
729
+ */
730
+ const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh']
731
+ const DEFAULT_BUTTON_SET = 'committing'
411
732
 
412
733
  /**
413
734
  * What an older sidecar's `approve` means now. Pure.
@@ -547,9 +868,13 @@ function writeNotes(outPath, notes) {
547
868
 
548
869
  const PENDING_VERSION = 1
549
870
 
550
- // Beside the page and the notes sidecar, under gitignored `.spec-env/`.
871
+ // Beside the page and the notes sidecar, under gitignored `.spec-env/`. The
872
+ // suffix is named once so the cross-spec scan recognises a store by the same
873
+ // spelling that writes one.
874
+ const PENDING_SUFFIX = '.pending.json'
875
+
551
876
  function reviewPendingPath(outPath) {
552
- return outPath.replace(/\.html$/, '') + '.pending.json'
877
+ return outPath.replace(/\.html$/, '') + PENDING_SUFFIX
553
878
  }
554
879
 
555
880
  function emptyPending(specFolder) {
@@ -637,6 +962,59 @@ function addPending(pending, { blob, at, render }, mint = mintPendingCode) {
637
962
  return { pending: next, code }
638
963
  }
639
964
 
965
+ /**
966
+ * What became of ONE pass, by its code. Reads; writes nothing, refuses nothing.
967
+ *
968
+ * The served page POSTs, is handed six digits, and then has to end on one of
969
+ * two sentences — Claude picked this up, or here is the command that picks it
970
+ * up. It cannot know which at send time, so it asks, and this answers.
971
+ *
972
+ * Three states, and the third is the point:
973
+ *
974
+ * - `waiting` — the code IS in the holding area. Nobody has claimed it.
975
+ * - `claimed` — the decision log NAMES the code. Somebody did.
976
+ * - `unknown` — neither could be established.
977
+ *
978
+ * BOTH ANSWERS REST ON SOMETHING PRESENT, which is what stops this being the
979
+ * fourth row in the table at the top of `.claude/rules/negative-checks.md`. The
980
+ * tempting cheap version reads `claimed` off the code being ABSENT from the
981
+ * holding area — and absence there has four causes, of which exactly one is a
982
+ * claim: `--drop` removes a pass and writes no decision, a store written under
983
+ * a different `--out` is a store this never opens, and a mistyped folder finds
984
+ * an empty one. Three of those four would report a pass as picked up while it
985
+ * sits in a file nobody is reading, which is this spec's own bug told back to
986
+ * the reader with confidence.
987
+ *
988
+ * So `unknown` answers everything that is not a positive signal, and the caller
989
+ * turns it back into the command. That direction is deliberate: a command
990
+ * nobody needed to run costs a glance, and a pass nobody claims costs the
991
+ * review.
992
+ *
993
+ * A code that is not six digits never reaches disk. It cannot be one this
994
+ * engine minted, so there is nothing to look up, and answering it from the
995
+ * filesystem would make a reachable endpoint into a path-shaped probe.
996
+ */
997
+ function passState(outPath, specFolder, code) {
998
+ const wanted = typeof code === 'string' ? code.trim() : ''
999
+ if (!new RegExp(`^\\d{${PENDING_CODE_LENGTH}}$`).test(wanted)) return { state: 'unknown' }
1000
+
1001
+ const held = readPending(outPath, specFolder)
1002
+ // A store we could not parse is not an empty one. It holds somebody's passes
1003
+ // and we simply cannot see them, so neither answer is available.
1004
+ if (held.corrupt) return { state: 'unknown' }
1005
+ if ((held.pending.passes || []).some((p) => p && p.code === wanted)) return { state: 'waiting' }
1006
+
1007
+ const stored = readNotes(outPath, specFolder)
1008
+ if (stored.corrupt) return { state: 'unknown' }
1009
+ const decisions = Array.isArray(stored.notes.decisions) ? stored.notes.decisions : []
1010
+ if (decisions.some((d) => d && d.code === wanted)) return { state: 'claimed' }
1011
+
1012
+ // Gone from the holding area and unnamed in the log. It may have been
1013
+ // dropped, it may predate this field, the store may not be the one the page
1014
+ // was served from. Say so.
1015
+ return { state: 'unknown' }
1016
+ }
1017
+
640
1018
  /**
641
1019
  * What is waiting, as the render should describe it. Pure.
642
1020
  *
@@ -658,6 +1036,11 @@ function describePending(pending) {
658
1036
  // `approve`, and the operator must be offered the word that describes
659
1037
  // what claiming it would do.
660
1038
  verdict: readVerdict((p.blob && p.blob.verdict) || null) || null,
1039
+ // AN ACTION IS REPORTED AS AN ACTION. A pass carrying one has no verdict,
1040
+ // and listing it as `no verdict` would describe the instruction it does
1041
+ // carry as an absence — which is the reading that makes a reader claim it
1042
+ // expecting a conclusion.
1043
+ action: (p.blob && p.blob.action) || null,
661
1044
  at: p.at || null,
662
1045
  }))
663
1046
  .sort((a, b) => String(a.at).localeCompare(String(b.at)) || a.code.localeCompare(b.code))
@@ -745,6 +1128,141 @@ function passesSince(pending, since) {
745
1128
  return { codes, usable: true }
746
1129
  }
747
1130
 
1131
+ /* ==========================================================================
1132
+ * The wait — one implementation, because improvised ones cannot be tested
1133
+ *
1134
+ * The skills used to say "watch the pending store and end your turn" and stop
1135
+ * there, so every run wrote its own watcher in shell. Three failed in two days,
1136
+ * each reaching the operator as "I pressed the button and nothing happened" —
1137
+ * and the worst of them was `until [ -f "$P" ] && [ "$x" \\> "$y" ]`, valid bash
1138
+ * and a syntax error in zsh, which spun for five minutes writing to a stderr
1139
+ * nobody reads.
1140
+ *
1141
+ * What made that expensive was not the typo. It is that SILENCE WAS THE SUCCESS
1142
+ * SIGNAL: from outside, a watcher that can never fire and one patiently working
1143
+ * look exactly alike. So the comparison lives here, in one place, where a test
1144
+ * can hand it a store that gains a pass mid-flight and watch it return.
1145
+ * ========================================================================== */
1146
+
1147
+ const WAIT_POLL_MS = 400
1148
+
1149
+ /**
1150
+ * Block until exactly one pass arrives inside a window. Impure only in that it
1151
+ * reads the store and sleeps; it writes nothing and claims nothing.
1152
+ *
1153
+ * Four outcomes, and they are distinct because the caller acts differently on
1154
+ * each:
1155
+ *
1156
+ * - `{ state: 'arrived', code }` — one pass, inside the window. Claim it.
1157
+ * - `{ state: 'ambiguous', count }` — more than one. NAMES NO CODE, because
1158
+ * choosing between two is exactly the guess `claimPending` refuses to make.
1159
+ * - `{ state: 'timeout' }` — only when a timeout was asked for.
1160
+ * - `{ state: 'unusable' }` — the window could not be parsed, so there is
1161
+ * nothing to wait inside. Returns AT ONCE rather than blocking forever on a
1162
+ * comparison that can never be satisfied — which is the failure this whole
1163
+ * function exists to stop.
1164
+ *
1165
+ * NO TIMEOUT UNLESS ASKED. `timeoutMs` omitted means wait as long as the
1166
+ * process lives, and that is the default the skills use: any fixed number is a
1167
+ * guess about how long someone reads, and a reader who walks away from a diff
1168
+ * is the normal case rather than the edge one. An hour was picked once and a
1169
+ * lunch break beat it.
1170
+ *
1171
+ * WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
1172
+ * store as a reason to return. None of those is a pass — they are the ordinary
1173
+ * state of a review nobody has answered yet — and a wait that ended on one
1174
+ * would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
1175
+ * rule 3 has a test for each).
1176
+ */
1177
+ async function waitForPass(readStore, since, { timeoutMs = null, pollMs = WAIT_POLL_MS, sleep } = {}) {
1178
+ if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
1179
+ const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
1180
+ const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? Date.now() + timeoutMs : null
1181
+
1182
+ for (;;) {
1183
+ // A store that will not parse is not an empty one, and it is not a pass
1184
+ // either — keep waiting rather than reading someone's unreadable passes as
1185
+ // an answer. `readPending` already reports `corrupt` rather than throwing.
1186
+ const read = readStore()
1187
+ if (read && !read.corrupt) {
1188
+ const window = passesSince(read.pending, since)
1189
+ if (window.usable) {
1190
+ if (window.codes.length === 1) return { state: 'arrived', code: window.codes[0] }
1191
+ if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
1192
+ }
1193
+ }
1194
+ if (deadline !== null && Date.now() >= deadline) return { state: 'timeout' }
1195
+ // Never overshoot the deadline by a whole poll interval.
1196
+ await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - Date.now())))
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Every waiting pass, across every spec. Reads; writes, claims and refuses
1202
+ * nothing.
1203
+ *
1204
+ * A wait that never fired, a session cleared, a terminal closed overnight —
1205
+ * none of those is recoverable by any watcher, however good. This is what finds
1206
+ * the pass afterwards, so recovery stops depending on the operator happening to
1207
+ * suspect something.
1208
+ *
1209
+ * IT READS THE SIDECAR DIRECTORY, NEVER THE PROVISIONED LIST, and that is the
1210
+ * load-bearing decision rather than an implementation detail. `specEnvStatus`
1211
+ * walks specs that have a **worktree**; the ten passes that motivated this all
1212
+ * belonged to specs that had been completed and torn down. Scoping the scan to
1213
+ * provisioned specs would therefore make it blind to precisely the case that
1214
+ * produced it. A `<spec>.pending.json` is here because a pass was received —
1215
+ * a positive signal (`.claude/rules/negative-checks.md` rule 1), and one that
1216
+ * outlives the spec's worktree, its branch and its folder.
1217
+ *
1218
+ * WHAT WOULD FOOL THIS: a store that will not parse. It holds someone's passes
1219
+ * and reading it as empty would report "nothing waiting", which is the one
1220
+ * answer that is certainly wrong — so it comes back named in `unreadable`
1221
+ * rather than counted as zero (rule 4).
1222
+ *
1223
+ * Oldest first and stable, ties broken on the code, exactly as
1224
+ * `describePending` orders one spec's own.
1225
+ */
1226
+ function waitingPasses(reviewsDirPath) {
1227
+ let entries
1228
+ try {
1229
+ entries = fs.readdirSync(reviewsDirPath)
1230
+ } catch {
1231
+ // No directory means no review has ever been rendered here. That is the
1232
+ // ordinary state of a fresh repo, not a failure to report.
1233
+ return { passes: [], unreadable: [] }
1234
+ }
1235
+
1236
+ const passes = []
1237
+ const unreadable = []
1238
+ for (const name of entries.sort()) {
1239
+ if (!name.endsWith(PENDING_SUFFIX)) continue
1240
+ const folder = name.slice(0, -PENDING_SUFFIX.length)
1241
+ // The engine's own reader, so "corrupt" means here what it means everywhere.
1242
+ const read = readPending(path.join(reviewsDirPath, `${folder}.html`), folder)
1243
+ if (read.corrupt) {
1244
+ unreadable.push(folder)
1245
+ continue
1246
+ }
1247
+ for (const pass of read.pending.passes || []) {
1248
+ if (pass && pass.code) {
1249
+ passes.push({
1250
+ spec: folder,
1251
+ code: pass.code,
1252
+ verdict: readVerdict(pass.blob && pass.blob.verdict),
1253
+ action: (pass.blob && pass.blob.action) || null,
1254
+ at: pass.at || null,
1255
+ })
1256
+ }
1257
+ }
1258
+ }
1259
+ passes.sort((a, b) => {
1260
+ const at = String(a.at || '').localeCompare(String(b.at || ''))
1261
+ return at !== 0 ? at : a.code.localeCompare(b.code)
1262
+ })
1263
+ return { passes, unreadable }
1264
+ }
1265
+
748
1266
  /* ==========================================================================
749
1267
  * The gate — a standing obligation to review, not a message in flight
750
1268
  *
@@ -968,7 +1486,20 @@ function validateNotesBlob(blob, specFolder) {
968
1486
  }
969
1487
  verdict = named
970
1488
  }
971
- return { accepted, unaccepted, comments, verdict }
1489
+ // AN ACTION, refused by name for the same reason a misspelt verdict is: a
1490
+ // dropped `action: "live-onn"` would read as a pass carrying no instruction
1491
+ // at all, reported as if the press had been honoured.
1492
+ let action = null
1493
+ if (blob.action !== undefined && blob.action !== null) {
1494
+ if (typeof blob.action !== 'string' || !ACTIONS.includes(blob.action)) {
1495
+ fail(`action ${JSON.stringify(blob.action)} is not one of ${ACTIONS.join(', ')}`)
1496
+ }
1497
+ action = blob.action
1498
+ }
1499
+ if (action && verdict) {
1500
+ fail(`carries both a verdict (${verdict}) and an action (${action}) — send one`)
1501
+ }
1502
+ return { accepted, unaccepted, comments, verdict, action }
972
1503
  }
973
1504
 
974
1505
  /**
@@ -995,7 +1526,15 @@ function validateNotesBlob(blob, specFolder) {
995
1526
  * failure we can afford.
996
1527
  */
997
1528
  function judgeVerdict(verdict, notes) {
998
- const sent = readVerdict(verdict) || null
1529
+ const read = readVerdict(verdict) || null
1530
+ // A WORD THIS ENGINE DOES NOT KNOW IS NOT HONOURED AS ONE. Every door into
1531
+ // here validates first — `validateNotesBlob` refuses an unknown verdict by
1532
+ // name and refuses an action in the verdict slot, and `--verdict` checks the
1533
+ // list — so nothing reaches this today. It used to pass such a word straight
1534
+ // through with `honoured: true`, which routes cannot-tell to the branch that
1535
+ // acts (`.claude/rules/negative-checks.md` rule 4 inverted). Closing it costs
1536
+ // nothing and means a fifth door added later cannot reopen it.
1537
+ const sent = read && VERDICTS.includes(read) ? read : null
999
1538
  const asked = sent || DEFAULT_VERDICT
1000
1539
  const open = (notes.comments || []).filter((c) => !c.resolved)
1001
1540
  const openFiles = [...new Set(open.map((c) => c.file))]
@@ -1023,9 +1562,18 @@ function judgeVerdict(verdict, notes) {
1023
1562
  * commit something nobody read. What is kept is the account of what was
1024
1563
  * decided, when, and eventually what it produced.
1025
1564
  */
1026
- function appendDecision(notes, { verdict, at, note = null }) {
1565
+ function appendDecision(notes, { verdict, at, note = null, code = null }) {
1027
1566
  const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
1028
- decisions.push({ verdict, at, note: note === undefined ? null : note })
1567
+ // `code` NAMES THE PASS this decision came out of, and it is the only present
1568
+ // thing anyone can assert to conclude that a pass was picked up. Four
1569
+ // different things make a code vanish from the holding area and only one of
1570
+ // them is a claim — `--drop` is another, a moved store and a mistyped folder
1571
+ // are two more — so `passState` reads this, and never reads an absence.
1572
+ //
1573
+ // `null` for every other path in: a pasted blob and a `--verdict` word carry
1574
+ // no code, and inventing one would put a pass in the record that never
1575
+ // existed.
1576
+ decisions.push({ verdict, at, note: note === undefined ? null : note, code: code || null })
1029
1577
  return { ...notes, updatedAt: at, decisions }
1030
1578
  }
1031
1579
 
@@ -1464,7 +2012,14 @@ module.exports = {
1464
2012
  WHOLE_FILE_CONTEXT,
1465
2013
  NOTES_VERSION,
1466
2014
  VERDICTS,
2015
+ ACTIONS,
2016
+ tierAction,
2017
+ surfacesFor,
2018
+ reviewTierStack,
2019
+ reviewTierLine,
1467
2020
  COMMITTING,
2021
+ BUTTON_SETS,
2022
+ DEFAULT_BUTTON_SET,
1468
2023
  readVerdict,
1469
2024
  DEFAULT_VERDICT,
1470
2025
  DELETED_HASH,
@@ -1479,6 +2034,7 @@ module.exports = {
1479
2034
  appendDecision,
1480
2035
  annotateLastDecision,
1481
2036
  reviewPendingPath,
2037
+ PENDING_SUFFIX,
1482
2038
  emptyPending,
1483
2039
  readPending,
1484
2040
  writePending,
@@ -1486,6 +2042,10 @@ module.exports = {
1486
2042
  addPending,
1487
2043
  claimPending,
1488
2044
  passesSince,
2045
+ waitingPasses,
2046
+ waitForPass,
2047
+ WAIT_POLL_MS,
2048
+ passState,
1489
2049
  describePending,
1490
2050
  pendingAge,
1491
2051
  PENDING_CODE_LENGTH,