@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
@@ -168,25 +168,58 @@ input:focus-visible,
168
168
  font-size: .85rem;
169
169
  font-weight: 600;
170
170
  }
171
- .tree { padding: 0 .6rem .7rem; font-size: .82rem; }
172
- .tree ul { list-style: none; margin: 0; padding-left: .8rem; }
173
- .tree > ul { padding-left: 0; }
174
- .tree .dir { color: var(--muted); display: block; padding: .1rem 0; }
171
+ /* The tree scrolls SIDEWAYS; names never wrap. A wrapped name turned every
172
+ long path into three ragged lines, which is what made the sidebar hard to
173
+ scan. Indentation is per-row padding rather than nested <ul> padding — see
174
+ renderTree, which explains why the sticky column below depends on it. */
175
+ .tree { padding: 0 0 .7rem; font-size: .82rem; overflow-x: auto; }
176
+ .tree ul { list-style: none; margin: 0; padding: 0; }
177
+ /* max-content makes the list as wide as its widest row, and min-width keeps it
178
+ filling the sidebar when everything fits. Together they give every row the
179
+ same width, which is what `.ct` needs. */
180
+ .tree > ul { width: max-content; min-width: 100%; }
181
+ .tree .dir { color: var(--muted); display: block; padding: .1rem .6rem; white-space: nowrap; }
175
182
  .tree-file {
176
183
  display: flex;
177
184
  gap: .4rem;
178
185
  width: 100%;
179
186
  text-align: left;
180
- background: none;
187
+ /* A COLOUR, not `none`: `.ct` inherits it to mask the name scrolling under
188
+ it, and inherit re-resolves on hover so the pinned counts follow the row. */
189
+ background: var(--panel);
181
190
  border: 0;
182
191
  border-radius: 6px;
183
- padding: .15rem .3rem;
192
+ /* NO horizontal padding, deliberately. A sticky box is clamped by its
193
+ containing block — this row's content box — so a .6rem side padding gave
194
+ `.ct` two different resting places: flush to the scrollport while pinned,
195
+ and .6rem inside it once scrolled to the end, which reads as the counts
196
+ sliding as you reach the right. The gutters live on `.nm` and `.ct`
197
+ instead, where they move with the element rather than fencing it in. */
198
+ padding: .15rem 0;
184
199
  color: inherit;
185
200
  font-size: .82rem;
186
201
  }
187
202
  .tree-file:hover { background: var(--panel-2); color: inherit; }
188
- .tree-file .nm { overflow-wrap: anywhere; }
189
- .tree-file .ct { margin-left: auto; white-space: nowrap; font-family: ui-monospace, monospace; font-size: .75rem; }
203
+ .tree-file .nm { white-space: nowrap; }
204
+ /* Pinned to the right edge of the tree's scrollport, so the change counts stay
205
+ readable however far the names are scrolled. Sticky can only pin within its
206
+ own row, which is why every row spans the full scroll width above. */
207
+ .tree-file .ct {
208
+ margin-left: auto;
209
+ position: sticky;
210
+ right: 0;
211
+ background: inherit;
212
+ /* The right gutter is PADDING on the pinned element, not a `right` offset.
213
+ An offset would hold the box away from the edge and let names scroll
214
+ visibly through the gap beside it; padding keeps the masking background
215
+ running to the scrollport edge with the text inset. */
216
+ padding-left: .55rem;
217
+ padding-right: .6rem;
218
+ box-shadow: -8px 0 8px -8px rgba(0, 0, 0, .45);
219
+ white-space: nowrap;
220
+ font-family: ui-monospace, monospace;
221
+ font-size: .75rem;
222
+ }
190
223
  .tree-file.is-noise .nm { color: var(--muted); }
191
224
 
192
225
  .file {
@@ -289,8 +322,63 @@ tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
289
322
  font-size: .78rem;
290
323
  color: var(--muted);
291
324
  overflow-wrap: anywhere;
325
+ /* A button, because it jumps to the code — but it must keep reading as the
326
+ quiet path label it has always been, not gain a control's chrome. */
327
+ background: none;
328
+ border: 0;
329
+ padding: 0;
330
+ text-align: left;
331
+ cursor: pointer;
332
+ text-decoration: underline;
333
+ text-decoration-style: dotted;
334
+ text-underline-offset: 2px;
335
+ }
336
+ .check-file:hover { color: inherit; }
337
+ /* WHO SAID IT. Absent on a written review's checks, which is why it is a
338
+ separate span rather than a prefix on the note: a page with one author looks
339
+ exactly as it always did. */
340
+ .check-src {
341
+ grid-column: 2;
342
+ font-size: .68rem;
343
+ text-transform: uppercase;
344
+ letter-spacing: .04em;
345
+ font-weight: 700;
346
+ color: var(--muted);
347
+ margin-right: .4rem;
292
348
  }
349
+ .check-src + .check-file { grid-column: 2; }
293
350
  .check-note { grid-column: 2; }
351
+
352
+ /* --- the reviewer strip --------------------------------------------------
353
+ One line per configured reviewer, present even when every one found nothing.
354
+ A reviewer that was rate-limited and one that read the diff and approved are
355
+ the same page without it, and the reader is about to decide whether to
356
+ commit. */
357
+ .reviewers { list-style: none; margin: 0 0 .8rem; padding: 0; display: grid; gap: .25rem; }
358
+ .reviewer {
359
+ display: flex;
360
+ flex-wrap: wrap;
361
+ gap: .5rem;
362
+ align-items: baseline;
363
+ font-size: .8rem;
364
+ color: var(--muted);
365
+ }
366
+ .reviewer-name {
367
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
368
+ font-weight: 700;
369
+ color: var(--fg);
370
+ }
371
+ .reviewer-name::after { content: ' —'; color: var(--muted); font-weight: 400; }
372
+ /* A reviewer that could not run is marked, not hidden — the distinction between
373
+ "clean" and "never answered" is the whole reason this strip exists. */
374
+ .reviewer.failed .reviewer-said,
375
+ .reviewer.missing .reviewer-said,
376
+ .reviewer.timeout .reviewer-said { color: var(--flag-fg); }
377
+ .reviewer.findings .reviewer-said { color: var(--fg); }
378
+
379
+ /* A line a check pointed at, so a jump lands somewhere visible rather than
380
+ leaving the reader to find the row themselves. */
381
+ tr.goto-hit > td { background: var(--confirm-bg); }
294
382
  .check.flag { background: var(--flag-bg); }
295
383
  .check.flag .check-level { color: var(--flag-fg); }
296
384
  .check.confirm { background: var(--confirm-bg); }
@@ -382,6 +470,28 @@ tr.note-row td {
382
470
  border-radius: 10px;
383
471
  background: var(--panel);
384
472
  }
473
+ /* WHERE THIS REVIEW LIVES, and whether it is also running. It sits ABOVE the
474
+ verdicts because the question it answers — do I want to look at this running
475
+ before I conclude? — comes before the conclusion, not after it. Visually its
476
+ own strip, because it is not a verdict: pressing one of these changes what is
477
+ running (or which surfaces are permitted) and hands the reader straight back
478
+ to the same page with the same options. */
479
+ .surfaces {
480
+ margin: 1.2rem 0 0;
481
+ padding: .55rem .7rem;
482
+ border: 1px solid var(--line);
483
+ border-radius: 10px;
484
+ background: var(--panel);
485
+ }
486
+ .surfaces[hidden] { display: none; }
487
+ .surfaces-title { display: block; font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; }
488
+ .surface-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .3rem 0; }
489
+ .surface-label { font-size: .8rem; font-weight: 600; min-width: 4.5rem; }
490
+ .surface-state { font-size: .8rem; color: var(--muted); }
491
+ .surface-note { font-size: .78rem; color: var(--muted); }
492
+ /* The command, for a page with no transport. Same treatment as the verdict
493
+ command list: plain text a single tap selects whole, never an input. */
494
+ .surface-cmd { font-size: .78rem; user-select: all; }
385
495
  .verdict-end { margin: 1.2rem 0 0; }
386
496
  .verdict-title { font-size: .8rem; color: var(--muted); }
387
497
  .verdict-count { font-size: .8rem; color: var(--muted); }
@@ -393,6 +503,13 @@ tr.note-row td {
393
503
  /* A button the render did not ask for is GONE, not dimmed — a disabled control
394
504
  invites a reader to work out how to enable it, and there is nothing to do. */
395
505
  .verdict button[hidden] { display: none; }
506
+ /* AND THE BAR ITSELF. `display: flex` beats the browser's `[hidden]`, so the
507
+ whole bar stayed on screen on a `file://` page — offering four buttons that
508
+ POST to a server that is not there, directly above the command list that
509
+ exists because they cannot. The guard in `assets-review` only read the
510
+ template for a `hidden` attribute, and this element is hidden at runtime, so
511
+ it never saw it; the guard reads the script now too. */
512
+ .verdict[hidden] { display: none; }
396
513
  /* A blocked control is dashed as well as dimmed, so the block survives a
397
514
  greyscale screen — and the reason rides in the label, never in a tooltip
398
515
  nobody hovers on a phone. */
@@ -448,7 +565,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
448
565
  .decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
449
566
  .decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
450
567
  .decided-note:last-child { margin-bottom: 0; }
451
- .decided-toggle { font-size: .78rem; }
568
+ /* SPACE ABOVE IT. The toggle sits under whatever the panel ended on — a
569
+ sentence, or the command callout — and butted straight against the callout's
570
+ border it read as part of it rather than as the way back to the diff. */
571
+ .decided-toggle { font-size: .78rem; margin-top: .8rem; }
452
572
 
453
573
  /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
454
574
  to be read rather than data to be scanned, so it gets a measure, air, and a
@@ -506,6 +626,17 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
506
626
  .context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
507
627
  .context-more[open] > summary { margin-bottom: .6rem; }
508
628
  .context li.done { color: var(--muted); }
629
+ /* The ticket rides ON the title line rather than under it — it is the other
630
+ name for this work, not a fact about it. Sized down and tinted so the title
631
+ still reads as the title. */
632
+ .ticket {
633
+ font-size: .62em; font-weight: 600; vertical-align: middle;
634
+ font-family: var(--mono, ui-monospace, monospace);
635
+ color: var(--accent); text-decoration: none;
636
+ padding: .12em .45em; border: 1px solid var(--line); border-radius: 999px;
637
+ white-space: nowrap;
638
+ }
639
+ a.ticket:hover { border-color: var(--accent); }
509
640
  .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
510
641
  .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
511
642
  .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
@@ -518,8 +649,102 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
518
649
  flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
519
650
  font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
520
651
  background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
652
+ /* One tap takes the whole command — the gesture that still works on a page
653
+ served over plain http, where the clipboard API is withheld. */
654
+ user-select: all; -webkit-user-select: all;
655
+ overflow-x: auto; white-space: pre; line-height: 1.6;
521
656
  }
522
657
  .sent-cmd-copy { white-space: nowrap; }
658
+ /* ONE GRID FOR THE WHOLE LIST, so the command column starts at the same x on
659
+ every row. A per-row layout cannot do that: each row would size its own label
660
+ column to its own label, and `Commit` and `Commit & Continue` are not the
661
+ same width. The label column is `max-content` — the longest label decides,
662
+ which is a fact about the labels rather than a rem value someone has to
663
+ remember to update when one changes. */
664
+ .cmd-list { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; margin: .6rem 0 0; }
665
+ .cmd-list[hidden] { display: none; }
666
+ .cmd-lead { grid-column: 1 / -1; font-size: .85rem; color: var(--muted); margin: 0 0 .5rem; }
667
+ /* ONE LINE: label, command, Copy. It was three lines deep — the label on its
668
+ own row above the command — which spent a screenful on four rows that fit on
669
+ four lines. */
670
+ .cmd-row {
671
+ display: grid;
672
+ grid-column: 1 / -1;
673
+ /* FALLBACK FIRST, then subgrid. A browser without subgrid drops the second
674
+ declaration and keeps this one, which still gives one line per row — just
675
+ each sized to itself. Without a fallback the row would fall back to a
676
+ single auto column and stack, which is worse than the misalignment. */
677
+ grid-template-columns: max-content minmax(0, 1fr) max-content;
678
+ grid-template-columns: subgrid;
679
+ gap: .5rem; align-items: center;
680
+ padding: .4rem .5rem; margin-bottom: .35rem;
681
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
682
+ }
683
+ .cmd-row[hidden] { display: none; }
684
+ .cmd-label { font-weight: 600; font-size: .85rem; white-space: nowrap; }
685
+ /* A TINTED EDGE, NOT A TINTED BOX. Four filled rows read as four warnings —
686
+ the colour shouted where it only needed to identify. The left border and the
687
+ label carry it now, which is enough to recognise the choice by and quiet
688
+ enough to read past. No new tokens: `v-commit` and `v-changes` above borrow
689
+ exactly this pair. */
690
+ .cmd-row[data-verdict="commit"],
691
+ .cmd-row[data-verdict="commit-start"],
692
+ .cmd-row[data-verdict="commit-land"],
693
+ .cmd-row[data-verdict="commit-continue"],
694
+ .cmd-row[data-verdict="continue"] { border-left: 3px solid var(--good-fg); }
695
+ .cmd-row[data-verdict="commit"] .cmd-label,
696
+ .cmd-row[data-verdict="commit-start"] .cmd-label,
697
+ .cmd-row[data-verdict="commit-land"] .cmd-label,
698
+ .cmd-row[data-verdict="commit-continue"] .cmd-label,
699
+ .cmd-row[data-verdict="continue"] .cmd-label { color: var(--good-fg); }
700
+ .cmd-row[data-verdict="changes"] { border-left: 3px solid var(--flag-fg); }
701
+ .cmd-row[data-verdict="changes"] .cmd-label { color: var(--flag-fg); }
702
+ /* Discuss keeps the neutral panel, exactly as its button does — it is the
703
+ ending that decides nothing, and colouring it would say otherwise. */
704
+ .cmd-text {
705
+ /* No `flex` — the list's grid column sizes this now. min-width:0 still
706
+ earns its place: it is what lets a long command scroll inside its own box
707
+ instead of widening the column and pushing Copy off the row. */
708
+ min-width: 0; font-family: var(--mono, ui-monospace, monospace);
709
+ font-size: .85rem; padding: .3rem .45rem; color: var(--fg);
710
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
711
+ /* One tap takes the whole command — see `.sent-cmd-text`. */
712
+ user-select: all; -webkit-user-select: all;
713
+ overflow-x: auto; white-space: pre; line-height: 1.6;
714
+ }
715
+ .cmd-copy { white-space: nowrap; }
716
+ /* AN ACTION, NOT A NOTE. The command reached the reader as a grey line under a
717
+ bar that had just closed, and it read as a footnote to something finished —
718
+ so it is now a filled callout inside the decided panel, which is the block a
719
+ re-opened page shows first. It reuses the add palette rather than minting a
720
+ green of its own: those three tokens are already defined on bare `:root` and
721
+ redefined in both theme blocks, which is the property `assets-review` guards. */
722
+ .sent-cmd.act {
723
+ flex-wrap: wrap; align-items: center; margin-top: .7rem;
724
+ padding: .55rem .6rem; border-radius: 6px;
725
+ background: var(--add-bg); border: 1px solid var(--add-mark); color: var(--add-fg);
726
+ }
727
+ .sent-cmd-lead { flex-basis: 100%; font-size: .85rem; font-weight: 600; color: var(--add-fg); }
728
+ /* A FAILED SEND, IN THE PALETTE OF A FAILURE. The same callout the waiting-pass
729
+ case uses, in the delete colours rather than the add ones — `--del-bg` /
730
+ `--del-fg` / `--del-mark` are already defined on bare `:root` and redefined in
731
+ both theme blocks, which is the property `assets-review` guards. No new token
732
+ is minted for this. */
733
+ .sent-cmd.act.fail {
734
+ background: var(--del-bg); border-color: var(--del-mark); color: var(--del-fg);
735
+ }
736
+ .sent-cmd.act.fail .sent-cmd-lead { color: var(--del-fg); }
737
+ /* THE OUTCOME THE READER MUST ACT ON, AS LOUD AS THE ONE THEY NEED NOT.
738
+ A failed send used to be a small grey line in the footer, under a verdict bar
739
+ that had just closed — quieter than the success it was not. It gets the panel
740
+ the decided page gets, at the top of the column, in the delete palette. What
741
+ it does NOT get is `is-decided`: nothing was delivered, so the diff stays and
742
+ the verdict buttons stay live for a second try. */
743
+ .decided.failed {
744
+ border-left-color: var(--del-mark);
745
+ background: var(--panel);
746
+ }
747
+ .decided.failed .decided-what { color: var(--del-mark); }
523
748
  /* The claim code is read aloud or copied, so it is set apart from the sentence
524
749
  around it rather than buried in it. */
525
750
  .copy-hint { user-select: text; }
@@ -548,12 +773,44 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
548
773
  engine from the spec's own files — nothing here passed through the
549
774
  model, so it is free however large the diff. Absent entirely for a spec
550
775
  whose overview cannot be read: the page worked without it before. -->
776
+ <!-- A SEND THAT DID NOT LAND. Same shape as the panel below, delete palette,
777
+ and deliberately NOT the same section: this one leaves the page
778
+ undecided, because nothing was delivered and the reader still has a
779
+ verdict to give. It names which failure happened, since a server that
780
+ has moved and a pass the engine refused send the reader to different
781
+ places. -->
782
+ <section class="decided failed" id="send-failed" hidden>
783
+ <p class="decided-what" id="send-failed-what"></p>
784
+ <p class="decided-note" id="send-failed-note"></p>
785
+ <p class="sent-cmd act fail" id="send-failed-cmd" hidden>
786
+ <span class="sent-cmd-lead" id="send-failed-lead"></span>
787
+ <code class="sent-cmd-text" id="send-failed-text"></code>
788
+ <button type="button" class="sent-cmd-copy" id="send-failed-copy" hidden>Copy</button>
789
+ </p>
790
+ </section>
551
791
  <!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
552
792
  above everything because on a re-open it is the only thing you need:
553
793
  the review is over, and this says how it ended. -->
554
794
  <section class="decided" id="decided" hidden>
555
795
  <p class="decided-what" id="decided-what"></p>
556
796
  <p class="decided-note" id="decided-note"></p>
797
+ <!-- THE ONE THING LEFT TO DO, and it lives HERE rather than under the
798
+ verdict bar because that is where it was being missed: a grey line
799
+ below a bar that had just closed, in the type size of a footnote,
800
+ saying the same words whether it mattered or not. It is shown only
801
+ where the page has ESTABLISHED that the pass is still sitting there,
802
+ so when it appears it is always an instruction. -->
803
+ <p class="sent-cmd act" id="sent-cmd" hidden>
804
+ <span class="sent-cmd-lead" id="sent-cmd-lead"></span>
805
+ <!-- PLAIN TEXT, NOT AN INPUT. A readonly field still looks like
806
+ something to type into, and on a phone tapping it raises the
807
+ keyboard for a value nobody can change. It is a command to read and
808
+ copy, so it is marked up as one — `user-select: all` makes a single
809
+ tap take the whole thing, which is the gesture that works where no
810
+ clipboard API does. -->
811
+ <code class="sent-cmd-text" id="sent-cmd-text"></code>
812
+ <button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
813
+ </p>
557
814
  <button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
558
815
  </section>
559
816
  <section class="context reviewable" id="context" hidden>
@@ -565,6 +822,13 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
565
822
  </section>
566
823
  <div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
567
824
  <div id="files" class="reviewable"></div>
825
+ <!-- THE SURFACES STRIP. Built by the script from `data.surfaces`, and
826
+ absent entirely when the render carried none — a `--docs` page has no
827
+ branch to put live, so it gets no strip and no explanation of why. -->
828
+ <section class="surfaces reviewable" id="surfaces" hidden>
829
+ <span class="surfaces-title">Where this runs</span>
830
+ <div id="surface-rows"></div>
831
+ </section>
568
832
  <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
569
833
  header and the first person to use it could not find it: you read a
570
834
  350-line diff downward, and the control asking for your conclusion was
@@ -582,16 +846,37 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
582
846
  read it, carry on. It is not the removed `none` verdict — that
583
847
  recorded itself and did nothing, while this one resumes the run. -->
584
848
  <button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
849
+ <!-- THE AUTHORING VERDICT, hidden unless the render asked for it. A spec
850
+ that has just been written has no phase in flight to continue, so
851
+ "Commit & Continue" is the wrong verb for it: this one commits the
852
+ spec and puts it IN FLIGHT, which is the `commit && /spec-start`
853
+ that was typed by hand until now. -->
854
+ <button id="verdict-commit-start" class="v-commit" type="button" hidden>&#10003; Commit &amp; Start</button>
855
+ <!-- THE /no-spec VERDICT, hidden unless the render asked for it. Work with
856
+ no spec has no next phase and nothing to put in flight, so neither
857
+ committing verb above fits: this one commits, fast-forwards the base
858
+ branch, and tears the worktree down. Its own word rather than a
859
+ context-dependent reading of "Commit", because one of those two
860
+ readings destroys a worktree. -->
861
+ <button id="verdict-commit-land" class="v-commit" type="button" hidden>&#10003; Commit &amp; Land</button>
585
862
  <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
586
863
  <button id="verdict-discuss" type="button">… Discuss first</button>
587
864
  <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
588
865
  </div>
866
+ <!-- WHERE THE BUTTONS CANNOT DELIVER, the commands stand in their place.
867
+ A `file://` page has no server to POST to and no store to write to,
868
+ so a verdict button there only ever built a blob for the reader to
869
+ paste — a wall of JSON standing in for one word. Each row is named
870
+ with the BUTTON'S own label, because that is the word the reader
871
+ chose everywhere else; `commit-continue` is the wire spelling, and
872
+ nobody should have to learn a second vocabulary to recognise their
873
+ own decision. Built by the script, so there is exactly one list of
874
+ verdicts and it is the one the bar already uses. -->
875
+ <div class="cmd-list" id="cmd-list" hidden>
876
+ <p class="cmd-lead" id="cmd-lead"></p>
877
+ </div>
589
878
  <p class="verdict-log" id="verdict-log" hidden></p>
590
879
  <p class="copy-hint" id="copy-hint" hidden></p>
591
- <p class="sent-cmd" id="sent-cmd" hidden>
592
- <input class="sent-cmd-text" id="sent-cmd-text" readonly aria-label="Command to run">
593
- <button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
594
- </p>
595
880
  <textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
596
881
  <p class="drawn-by" id="drawn-by"></p>
597
882
  </footer>
@@ -843,6 +1128,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
843
1128
  } else {
844
1129
  tr.appendChild(gutter(row, st, i))
845
1130
  tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
1131
+ // The line number, so a check can point at this row. Set here rather
1132
+ // than derived on demand because the file re-renders on every expand,
1133
+ // and a lookup built once would go stale the first time a band opened.
1134
+ if (row.n != null) tr.setAttribute('data-n', String(row.n))
846
1135
  }
847
1136
  body.appendChild(tr)
848
1137
  // Anything said about this line sits directly beneath it — a note you
@@ -1089,28 +1378,104 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1089
1378
  d.scrollIntoView({ block: 'start' })
1090
1379
  }
1091
1380
 
1381
+ /**
1382
+ * Jump to a file, and to one line inside it when a check named one.
1383
+ *
1384
+ * THREE STATES, AND ONLY ONE OF THEM IS A JUMP. A file that is not in this
1385
+ * diff, and a line that is not in that file's patch, both do nothing beyond
1386
+ * revealing what IS there — a reviewer's finding can name a line that has
1387
+ * since moved, and a page that scrolled somewhere arbitrary would be worse
1388
+ * than one that scrolled nowhere.
1389
+ */
1390
+ function gotoLine(path, line) {
1391
+ var st = null
1392
+ state.forEach(function (s) { if (s.file.path === path) st = s })
1393
+ if (!st) return
1394
+ reveal(st.file)
1395
+ if (line == null) return
1396
+ // A collapsed band hides the row, so drop any band covering it first. The
1397
+ // file re-renders anyway; this just decides what is on screen when it does.
1398
+ var idx = -1
1399
+ for (var i = 0; i < st.rows.length; i++) {
1400
+ if (st.rows[i].n === line) { idx = i; break }
1401
+ }
1402
+ if (idx < 0) return
1403
+ st.bands = st.bands.filter(function (b) { return !(idx >= b.from && idx < b.to) })
1404
+ renderFile(st)
1405
+ if (!st.el) return
1406
+ // Attribute-only, no tag prefix: within one file's element nothing but a
1407
+ // code row carries `data-n`, and the narrower selector buys nothing.
1408
+ var tr = st.el.querySelector('[data-n="' + line + '"]')
1409
+ if (!tr) return
1410
+ var prev = document.querySelector('.goto-hit')
1411
+ if (prev) prev.className = prev.className.replace(/ ?goto-hit/, '')
1412
+ tr.className += ' goto-hit'
1413
+ tr.scrollIntoView({ block: 'center' })
1414
+ }
1415
+
1416
+ // Wired per button rather than delegated from `document`. The review block is
1417
+ // spliced in as markup by the engine, so it is all present by the time this
1418
+ // runs — and a listener on each element is what the rest of this page does.
1419
+ function wireGotos() {
1420
+ var btns = document.querySelectorAll('[data-goto]')
1421
+ Array.prototype.forEach.call(btns, function (btn) {
1422
+ btn.addEventListener('click', function () {
1423
+ var n = btn.getAttribute('data-goto-line')
1424
+ gotoLine(btn.getAttribute('data-goto'), n == null ? null : parseInt(n, 10))
1425
+ })
1426
+ })
1427
+ }
1428
+
1429
+ // ONE FLAT LIST, indented by per-row padding rather than by nesting <ul>s.
1430
+ //
1431
+ // That is not a tidy-up: the change counts are `position: sticky; right: 0`,
1432
+ // and a sticky box can only pin within its own row. Nested <ul>s make each
1433
+ // level narrower than its parent, so a deep row ends before the scrollport's
1434
+ // right edge and its counts slide away the moment you scroll sideways. Flat
1435
+ // rows all share the widest row's width, so every one of them has something
1436
+ // to pin to.
1437
+ //
1438
+ // Depth goes on as an inline style because it is data, not a class: the tree
1439
+ // is however deep the diff happens to be, and a rule per level would be a
1440
+ // guess at the maximum.
1441
+ function indent(node, depth, base) {
1442
+ // Rounded, because 3 * 0.8 is 2.4000000000000004 in binary floating point
1443
+ // and that lands verbatim in the attribute. CSS copes; a person reading the
1444
+ // page source does not, and neither does a test asserting the value.
1445
+ var rem = Math.round((base + depth * 0.8) * 100) / 100
1446
+ node.setAttribute('style', 'padding-left:' + rem + 'rem')
1447
+ return node
1448
+ }
1449
+
1092
1450
  function renderTree(node) {
1093
1451
  var ul = el('ul')
1094
- Object.keys(node.dirs).sort().forEach(function (name) {
1095
- var li = el('li')
1096
- li.appendChild(el('span', 'dir', name + '/'))
1097
- li.appendChild(renderTree(node.dirs[name]))
1098
- ul.appendChild(li)
1099
- })
1100
- node.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
1101
- var li = el('li')
1102
- var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
1103
- b.type = 'button'
1104
- b.appendChild(el('span', 'nm', entry.name))
1105
- var ct = el('span', 'ct')
1106
- ct.appendChild(el('span', 'a', '+' + entry.file.additions))
1107
- ct.appendChild(document.createTextNode(' '))
1108
- ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
1109
- b.appendChild(ct)
1110
- b.addEventListener('click', function () { reveal(entry.file) })
1111
- li.appendChild(b)
1112
- ul.appendChild(li)
1113
- })
1452
+ ;(function walk(n, depth) {
1453
+ Object.keys(n.dirs).sort().forEach(function (name) {
1454
+ var li = el('li')
1455
+ li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
1456
+ ul.appendChild(li)
1457
+ // The subtree goes in AFTER its own label and before this level's
1458
+ // files, which is the order the nested form produced.
1459
+ walk(n.dirs[name], depth + 1)
1460
+ })
1461
+ n.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
1462
+ var li = el('li')
1463
+ var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
1464
+ b.type = 'button'
1465
+ // The same .6rem base as a dir label, so a file sits level with the
1466
+ // directory it is in. It is on the name rather than the row because the
1467
+ // row must reach the scrollport edge — see `.tree-file` padding.
1468
+ b.appendChild(indent(el('span', 'nm', entry.name), depth, 0.6))
1469
+ var ct = el('span', 'ct')
1470
+ ct.appendChild(el('span', 'a', '+' + entry.file.additions))
1471
+ ct.appendChild(document.createTextNode(' '))
1472
+ ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
1473
+ b.appendChild(ct)
1474
+ b.addEventListener('click', function () { reveal(entry.file) })
1475
+ li.appendChild(b)
1476
+ ul.appendChild(li)
1477
+ })
1478
+ })(node, 0)
1114
1479
  return ul
1115
1480
  }
1116
1481
 
@@ -1228,6 +1593,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1228
1593
  var verdictBtns = {
1229
1594
  commit: document.getElementById('verdict-commit'),
1230
1595
  'commit-continue': document.getElementById('verdict-commit-continue'),
1596
+ 'commit-start': document.getElementById('verdict-commit-start'),
1597
+ 'commit-land': document.getElementById('verdict-commit-land'),
1231
1598
  continue: document.getElementById('verdict-continue'),
1232
1599
  changes: document.getElementById('verdict-changes'),
1233
1600
  discuss: document.getElementById('verdict-discuss'),
@@ -1235,7 +1602,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1235
1602
  // The committing pair, named once. A fifth verdict that commits is added
1236
1603
  // here, and the shared block below follows for free — the same reason the
1237
1604
  // engine keeps a `COMMITTING` list rather than a second condition.
1238
- var COMMITTERS = ['commit', 'commit-continue']
1605
+ var COMMITTERS = ['commit', 'commit-continue', 'commit-start', 'commit-land']
1239
1606
 
1240
1607
  /*
1241
1608
  * WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
@@ -1250,7 +1617,16 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1250
1617
  * changes.
1251
1618
  */
1252
1619
  var MIDRUN = data.buttons === 'midrun'
1253
- var OFFERED = MIDRUN ? ['continue', 'changes', 'discuss'] : ['commit', 'commit-continue', 'changes', 'discuss']
1620
+ // ONE TABLE, keyed by the set the engine named. A chain of ternaries is how a
1621
+ // third set becomes a fourth condition somebody forgets to add a button to.
1622
+ var OFFERS = {
1623
+ midrun: ['continue', 'changes', 'discuss'],
1624
+ authoring: ['commit-start', 'commit', 'changes', 'discuss'],
1625
+ refresh: ['commit', 'changes', 'discuss'],
1626
+ nospec: ['commit-land', 'commit', 'changes', 'discuss'],
1627
+ committing: ['commit', 'commit-continue', 'changes', 'discuss'],
1628
+ }
1629
+ var OFFERED = OFFERS[data.buttons] || OFFERS.committing
1254
1630
  Object.keys(verdictBtns).forEach(function (key) {
1255
1631
  verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
1256
1632
  })
@@ -1263,6 +1639,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1263
1639
  var LABELS = {
1264
1640
  commit: '✓ Commit',
1265
1641
  'commit-continue': '✓ Commit & Continue',
1642
+ 'commit-start': '✓ Commit & Start',
1643
+ 'commit-land': '✓ Commit & Land',
1266
1644
  continue: '→ Continue',
1267
1645
  changes: '↺ Request changes',
1268
1646
  discuss: '… Discuss first',
@@ -1272,11 +1650,19 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1272
1650
  // Says what it will NOT do: a reader must not press this expecting the
1273
1651
  // spec to be finished and landed.
1274
1652
  'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1653
+ // Says what it WILL do, because this is the one verdict that provisions:
1654
+ // a worktree appears and phase 1 starts.
1655
+ 'commit-start': 'Commit this spec, then put it in flight and build phase 1',
1656
+ // Says what it WILL do, and every destructive part of it: this is the only
1657
+ // verdict that removes a worktree, so the reader must not learn that from
1658
+ // the outcome afterwards.
1659
+ 'commit-land': 'Commit this, fast-forward the base branch to it, and remove the worktree',
1275
1660
  // Says what it will NOT do, for the same reason: nothing is committed by
1276
1661
  // this, so the work it resumes is still uncommitted afterwards.
1277
1662
  continue: 'You have read it — carry on with the run; nothing is committed',
1278
1663
  }
1279
1664
 
1665
+ var verdictEl = document.getElementById('verdict')
1280
1666
  var verdictCount = document.getElementById('verdict-count')
1281
1667
  var verdictLog = document.getElementById('verdict-log')
1282
1668
  var copyOut = document.getElementById('copy-out')
@@ -1289,6 +1675,14 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1289
1675
  var sentCmd = document.getElementById('sent-cmd')
1290
1676
  var sentCmdText = document.getElementById('sent-cmd-text')
1291
1677
  var sentCmdCopy = document.getElementById('sent-cmd-copy')
1678
+ var sentCmdLead = document.getElementById('sent-cmd-lead')
1679
+ var failBox = document.getElementById('send-failed')
1680
+ var failWhat = document.getElementById('send-failed-what')
1681
+ var failNote = document.getElementById('send-failed-note')
1682
+ var failCmd = document.getElementById('send-failed-cmd')
1683
+ var failLead = document.getElementById('send-failed-lead')
1684
+ var failText = document.getElementById('send-failed-text')
1685
+ var failCopy = document.getElementById('send-failed-copy')
1292
1686
 
1293
1687
  function pending() {
1294
1688
  var b = buildBlob()
@@ -1320,6 +1714,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1320
1714
  // the two committing buttons unconditionally, so without this it would undo
1321
1715
  // the ending every time anything redrew.
1322
1716
  if (decided) return drawDecided()
1717
+ // Which shape this page offers is re-answered here rather than at load: a
1718
+ // reader who ticks an accept half-way through has changed what their pass
1719
+ // contains, and a command line cannot carry the new contents.
1720
+ drawCmdList()
1323
1721
  var n = pending()
1324
1722
  var open = openNotes()
1325
1723
  verdictCount.textContent = n
@@ -1391,6 +1789,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1391
1789
  'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
1392
1790
  'Nothing here reaches Claude any more, so the verdict buttons are closed.'
1393
1791
  wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
1792
+ // The commands go with the bar. A decided page offers no way to decide
1793
+ // again, whichever shape it was offering.
1794
+ if (cmdList) cmdList.hidden = true
1394
1795
  // EVERY button, not only the committing pair: `refresh` re-enables those two
1395
1796
  // by design, and a decided page must beat it.
1396
1797
  Object.keys(verdictBtns).forEach(function (key) {
@@ -1404,29 +1805,54 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1404
1805
  // sentence again in smaller type, so it goes.
1405
1806
  verdictLog.hidden = true
1406
1807
 
1808
+ // WHAT THE PAGE ESTABLISHED, replayed — never re-derived. `picked` is the
1809
+ // answer the poll got, and a re-opened tab has no way to ask again for a
1810
+ // pass that is long gone, so it says what it said before rather than
1811
+ // quietly changing its mind a day later.
1812
+ copyHint.hidden = true
1813
+ copyOut.hidden = true
1814
+ if (decided.picked === true) {
1815
+ // NOTHING TO RUN, and so nothing offered. This is the whole point of
1816
+ // asking: the command used to appear here on every single ending, which
1817
+ // taught the reader it was decoration.
1818
+ sentCmd.hidden = true
1819
+ decidedNote.textContent += ' Claude picked this up — there is nothing to run.'
1820
+ return
1821
+ }
1407
1822
  // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
1408
1823
  // worth carrying. A code is six digits to transcribe, and the box and its
1409
1824
  // Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
1410
1825
  // reader is about to type into the terminal they are already sitting in;
1411
1826
  // dressing it as a copyable artefact makes the ending look unfinished.
1412
1827
  if (decided.cmd && /\s/.test(decided.cmd)) {
1413
- copyHint.hidden = false
1414
- copyHint.textContent = 'Sent. Run this where Claude is:'
1828
+ decidedNote.textContent += ' It is still waiting to be picked up.'
1415
1829
  showCommand(decided.cmd)
1416
1830
  return
1417
1831
  }
1418
- copyHint.hidden = true
1419
1832
  sentCmd.hidden = true
1420
- copyOut.hidden = true
1421
1833
  if (decided.cmd) {
1422
1834
  decidedNote.textContent +=
1423
1835
  ' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
1424
1836
  }
1425
1837
  }
1426
1838
 
1427
- /** Record a verdict that was actually delivered, and end the page on it. */
1428
- function markDecided(verdict, cmd) {
1429
- decided = { verdict: verdict, at: new Date().toISOString(), cmd: cmd || null }
1839
+ /**
1840
+ * Record a verdict that was actually delivered, and end the page on it.
1841
+ *
1842
+ * `picked` is three-valued and the third value is the useful one: `true` the
1843
+ * engine confirmed somebody claimed this pass, `false` it confirmed nobody
1844
+ * has, and `null` NOTHING COULD BE ESTABLISHED — an older daemon with no
1845
+ * lookup, a poll that never came back, a page that stopped asking. Only
1846
+ * `true` takes the command away, so every way of not knowing leaves it on
1847
+ * screen (`.claude/rules/negative-checks.md` rule 4).
1848
+ */
1849
+ function markDecided(verdict, cmd, picked) {
1850
+ decided = {
1851
+ verdict: verdict,
1852
+ at: new Date().toISOString(),
1853
+ cmd: cmd || null,
1854
+ picked: picked === undefined ? null : picked,
1855
+ }
1430
1856
  save()
1431
1857
  drawDecided()
1432
1858
  }
@@ -1447,6 +1873,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1447
1873
  approve: 'committed',
1448
1874
  commit: 'committed',
1449
1875
  'commit-continue': 'committed, then carried on',
1876
+ 'commit-start': 'committed, then put in flight',
1877
+ 'commit-land': 'committed, landed, and torn down',
1450
1878
  continue: 'read it and carried on',
1451
1879
  changes: 'changes requested',
1452
1880
  discuss: 'discussed',
@@ -1482,29 +1910,122 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1482
1910
  * here, not an edge one. A Copy button that does nothing when tapped is worse
1483
1911
  * than text that was always text, so the button only exists where it can work.
1484
1912
  */
1913
+ /**
1914
+ * Put the whole command in the reader's selection. Returns whether it worked.
1915
+ *
1916
+ * REPORTED, NOT ASSUMED. It replaces `input.select()`, which the box no longer
1917
+ * has anything to call — and the honest thing to do when a browser withholds
1918
+ * `getSelection` is to stop claiming the text is selected, rather than say so
1919
+ * and leave someone hunting for a highlight that is not there.
1920
+ */
1921
+ function selectCommand() {
1922
+ return selectNode(sentCmdText)
1923
+ }
1924
+
1925
+ /**
1926
+ * Put a node's whole text in the reader's selection. Returns whether it took.
1927
+ */
1928
+ function selectNode(node) {
1929
+ try {
1930
+ var range = document.createRange()
1931
+ range.selectNodeContents(node)
1932
+ var sel = window.getSelection && window.getSelection()
1933
+ if (!sel) return false
1934
+ sel.removeAllRanges()
1935
+ sel.addRange(range)
1936
+ return true
1937
+ } catch (e) {
1938
+ return false
1939
+ }
1940
+ }
1941
+
1942
+ /**
1943
+ * Can this browser copy AT ALL — by either route?
1944
+ *
1945
+ * WHAT THIS GOT WRONG, and why the button kept vanishing where it was needed
1946
+ * most: `navigator.clipboard` is **secure-context only**, so it is absent on
1947
+ * every `http://<lan-ip>:7777` page — which is exactly the page someone reads
1948
+ * on a phone. Asking only about that API therefore hid the Copy button in the
1949
+ * one case the button exists for, and left a phone reader long-pressing to
1950
+ * select text on a page that could have copied for them all along.
1951
+ *
1952
+ * `document.execCommand('copy')` is deprecated and universally implemented,
1953
+ * and it works in a non-secure context inside a user gesture. So there are
1954
+ * two routes, and the rule below is unchanged: offer the button only where
1955
+ * one of them exists.
1956
+ */
1957
+ function canCopy() {
1958
+ return Boolean(
1959
+ (navigator.clipboard && navigator.clipboard.writeText) ||
1960
+ (document.execCommand && window.getSelection),
1961
+ )
1962
+ }
1963
+
1964
+ /**
1965
+ * Copy `text`, by whichever route this browser has. Resolves on success and
1966
+ * rejects otherwise, so every caller reports the same three outcomes.
1967
+ *
1968
+ * `node` is what gets left selected when copying fails — the reader can then
1969
+ * do it by hand, which is the behaviour this button replaced rather than
1970
+ * removed.
1971
+ */
1972
+ function copyText(text, node) {
1973
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1974
+ return navigator.clipboard.writeText(text)
1975
+ }
1976
+ return new Promise(function (resolve, reject) {
1977
+ // The selection IS the mechanism here, not a consolation: execCommand
1978
+ // copies what is selected. So it is made first and deliberately left in
1979
+ // place — a failed copy then lands the reader exactly where the old
1980
+ // no-clipboard path did.
1981
+ if (!selectNode(node)) return reject(new Error('no selection'))
1982
+ var ok = false
1983
+ try {
1984
+ ok = document.execCommand('copy')
1985
+ } catch (e) {
1986
+ ok = false
1987
+ }
1988
+ return ok ? resolve() : reject(new Error('execCommand refused'))
1989
+ })
1990
+ }
1991
+
1485
1992
  function showCommand(cmd) {
1486
1993
  sentCmd.hidden = false
1487
- sentCmdText.value = cmd
1488
- var canCopy = Boolean(navigator.clipboard && navigator.clipboard.writeText)
1489
- sentCmdCopy.hidden = !canCopy
1490
- if (canCopy) return
1491
- // Selected, and SAID to be selected: a reader who sees nothing happen has
1492
- // no way to tell a page that did nothing from one that already did the work.
1493
- sentCmdText.focus()
1494
- sentCmdText.select()
1495
- copyHint.textContent = copyHint.textContent + ' (selected — copy it)'
1994
+ sentCmdText.textContent = cmd
1995
+ var copyable = canCopy()
1996
+ sentCmdCopy.hidden = !copyable
1997
+ sentCmdCopy.textContent = 'Copy'
1998
+ // SET HERE, NOT LEFT TO THE MARKUP. The lead says two different things and
1999
+ // the box is shown more than once in a page's life, so a line that was only
2000
+ // ever correct on first render would go stale the moment either changed.
2001
+ if (copyable) {
2002
+ sentCmdLead.textContent = 'Run this where Claude is:'
2003
+ // NOT SELECTED when there is a button. Pre-selecting made the line look
2004
+ // like a focused input, which is what it was mistaken for — and it is the
2005
+ // long-press-to-select gesture the button is here to spare a phone reader.
2006
+ return
2007
+ }
2008
+ // Selected where that is possible, and SAID to be selected: a reader who
2009
+ // sees nothing happen has no way to tell a page that did nothing from one
2010
+ // that already did the work. The lead carries it now that the box lives in
2011
+ // the decided panel — `copy-hint` is down in the footer with the verdict
2012
+ // bar, and appending to it put the sentence half a page from the thing it
2013
+ // describes.
2014
+ sentCmdLead.textContent = selectCommand()
2015
+ ? 'Run this where Claude is (selected — copy it):'
2016
+ : 'Run this where Claude is — copy it:'
1496
2017
  }
1497
2018
 
1498
2019
  if (sentCmdCopy) {
1499
2020
  sentCmdCopy.addEventListener('click', function () {
1500
- navigator.clipboard.writeText(sentCmdText.value).then(function () {
2021
+ copyText(sentCmdText.textContent, sentCmdText).then(function () {
1501
2022
  sentCmdCopy.textContent = 'Copied'
1502
2023
  }, function () {
1503
2024
  // The capability was there and the write still failed. Fall back to the
1504
- // same selection the no-clipboard path uses rather than saying nothing.
1505
- sentCmdText.focus()
1506
- sentCmdText.select()
1507
- sentCmdCopy.textContent = 'Copy failed — selected'
2025
+ // same selection the no-clipboard path uses rather than saying nothing —
2026
+ // and if even that is unavailable, say THAT, because a button reading
2027
+ // "selected" over nothing highlighted is worse than an honest failure.
2028
+ sentCmdCopy.textContent = selectCommand() ? 'Copy failed — selected' : 'Copy failed'
1508
2029
  })
1509
2030
  })
1510
2031
  }
@@ -1533,16 +2054,64 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1533
2054
  into.appendChild(h)
1534
2055
  }
1535
2056
 
2057
+ /**
2058
+ * THE PHASE COMES FIRST, and the spec's Problem is folded away behind it.
2059
+ *
2060
+ * The open part of this header used to be `Why` — the overview's Problem,
2061
+ * which is about the whole spec. A reader opening a page for phase 3 got
2062
+ * the same paragraph they had already read at phase 1, and had to expand
2063
+ * `More` to reach the only thing that told them what THIS review covers.
2064
+ * The question a review page answers is "what am I looking at", so the
2065
+ * phase answers it, and the background it sits in is one tap away.
2066
+ *
2067
+ * The TASK LIST is open too, deliberately. It is the cheapest statement of
2068
+ * what is and is not in this diff — which matters most half-way through a
2069
+ * phase, where a `/spec-diff` shows three ticked boxes and four empty ones
2070
+ * and the empty ones are the reason the diff looks unfinished.
2071
+ */
2072
+ if (ctx.phase) {
2073
+ head(why, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
2074
+ if (ctx.phase.goal) {
2075
+ var g = document.createElement('p')
2076
+ g.textContent = ctx.phase.goal
2077
+ why.appendChild(g)
2078
+ }
2079
+ if (ctx.phase.tasks && ctx.phase.tasks.length) {
2080
+ var ul = document.createElement('ul')
2081
+ ul.className = 'tasks'
2082
+ ctx.phase.tasks.forEach(function (task) {
2083
+ var li = document.createElement('li')
2084
+ // Done is dimmed rather than struck through: every task is ticked by
2085
+ // the time a phase renders, and a whole list struck through reads as
2086
+ // cancelled work.
2087
+ li.className = task.done ? 'done' : ''
2088
+ li.textContent = (task.done ? '✅ ' : '⬜ ') + task.text
2089
+ ul.appendChild(li)
2090
+ })
2091
+ why.appendChild(ul)
2092
+ }
2093
+ }
2094
+
1536
2095
  if (ctx.problem) {
1537
- // FIRST PARAGRAPH OPEN, the remainder folded. A phone shows perhaps six
1538
- // lines before the file list is pushed off-screen, and pushing it off is
1539
- // the thing this header was built to avoid.
2096
+ // FOLDED, ALL OF IT. With the phase open above, the spec's Problem is
2097
+ // background rather than the answer — and on a phone every line of it is
2098
+ // a line of the file list pushed off-screen, which is the thing this
2099
+ // header was built to avoid. Where there is NO phase (a branch-wide diff,
2100
+ // a spec with no live phase) the first paragraph stays open, because then
2101
+ // it is the only thing the header can say.
1540
2102
  var paras = String(ctx.problem).split(/\n\s*\n/)
1541
- head(why, 'Why')
1542
- var lead = document.createElement('p')
1543
- lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
1544
- why.appendChild(lead)
1545
- for (var i = 1; i < paras.length; i++) {
2103
+ var from = 0
2104
+ if (!ctx.phase) {
2105
+ head(why, 'Why')
2106
+ var lead = document.createElement('p')
2107
+ lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
2108
+ why.appendChild(lead)
2109
+ from = 1
2110
+ } else if (paras.length) {
2111
+ head(rest, 'Why')
2112
+ drewRest = true
2113
+ }
2114
+ for (var i = from; i < paras.length; i++) {
1546
2115
  var p = document.createElement('p')
1547
2116
  p.textContent = paras[i].replace(/\s+/g, ' ').trim()
1548
2117
  rest.appendChild(p)
@@ -1579,29 +2148,6 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1579
2148
  }
1580
2149
  }
1581
2150
 
1582
- if (ctx.phase) {
1583
- head(rest, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
1584
- drewRest = true
1585
- if (ctx.phase.goal) {
1586
- var g = document.createElement('p')
1587
- g.textContent = ctx.phase.goal
1588
- rest.appendChild(g)
1589
- }
1590
- if (ctx.phase.tasks && ctx.phase.tasks.length) {
1591
- var ul = document.createElement('ul')
1592
- ctx.phase.tasks.forEach(function (t) {
1593
- var li = document.createElement('li')
1594
- // Done is dimmed rather than struck through: every task is ticked by
1595
- // the time a phase renders, and a whole list struck through reads as
1596
- // cancelled work.
1597
- li.className = t.done ? 'done' : ''
1598
- li.textContent = (t.done ? '✅ ' : '⬜ ') + t.text
1599
- ul.appendChild(li)
1600
- })
1601
- rest.appendChild(ul)
1602
- }
1603
- }
1604
-
1605
2151
  document.getElementById('context').hidden = false
1606
2152
  more.hidden = !drewRest
1607
2153
  }
@@ -1615,6 +2161,91 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1615
2161
  copyHint.textContent = why
1616
2162
  }
1617
2163
 
2164
+ /**
2165
+ * Put the whole recovery command in the failure callout, with its Copy control.
2166
+ *
2167
+ * THE SAME CONTROL THE `file://` PAGE ALREADY USES — `/spec-reviewed <verdict>`
2168
+ * — so there is one command row rather than a second kind. It reaches the
2169
+ * agent regardless of any server, which is the only reason a recovery can be
2170
+ * offered from a failed send at all.
2171
+ *
2172
+ * `canCopy` decides whether there is a button, exactly as `showCommand` does:
2173
+ * a control that does nothing on tap reads as a broken page, and one that is
2174
+ * missing where it would have worked reads as a page that never offered it.
2175
+ */
2176
+ function showFailCommand(cmd) {
2177
+ failCmd.hidden = false
2178
+ failText.textContent = cmd
2179
+ var copyable = canCopy()
2180
+ failCopy.hidden = !copyable
2181
+ failCopy.textContent = 'Copy'
2182
+ failLead.textContent = copyable
2183
+ ? 'Your verdict is not lost — run this where Claude is:'
2184
+ : selectNode(failText)
2185
+ ? 'Your verdict is not lost — run this where Claude is (selected — copy it):'
2186
+ : 'Your verdict is not lost — run this where Claude is — copy it:'
2187
+ }
2188
+
2189
+ if (failCopy) {
2190
+ failCopy.addEventListener('click', function () {
2191
+ copyText(failText.textContent, failText).then(function () {
2192
+ failCopy.textContent = 'Copied'
2193
+ }, function () {
2194
+ failCopy.textContent = selectNode(failText) ? 'Copy failed — selected' : 'Copy failed'
2195
+ })
2196
+ })
2197
+ }
2198
+
2199
+ /** Take the failure panel down — a fresh attempt, or one that landed. */
2200
+ function clearSendFailure() {
2201
+ failBox.hidden = true
2202
+ failCmd.hidden = true
2203
+ failWhat.textContent = ''
2204
+ failNote.textContent = ''
2205
+ }
2206
+
2207
+ /**
2208
+ * Say that a send did not land, as loudly as a send that did.
2209
+ *
2210
+ * THE ONE OUTCOME THE READER MUST ACT ON used to be reported more quietly than
2211
+ * the one they need do nothing about: a small grey line in the footer, under a
2212
+ * verdict bar that had just closed. It gets the panel at the top of the column
2213
+ * now, in the delete palette.
2214
+ *
2215
+ * IT DOES NOT DECIDE THE PAGE. Nothing was delivered, so the diff stays and
2216
+ * the verdict buttons stay live — a page that closed its controls over a
2217
+ * failed send would strand the reader with no way to try again.
2218
+ *
2219
+ * A pass carrying MARKS still goes to the clipboard, because a command line
2220
+ * cannot carry notes and a failed send must not become the moment they are
2221
+ * silently dropped.
2222
+ */
2223
+ function sendFailed(json, verdict, what, note) {
2224
+ copyHint.hidden = true
2225
+ copyHint.textContent = ''
2226
+ failBox.hidden = false
2227
+ failWhat.textContent = what
2228
+ if (pending() > 0) {
2229
+ failCmd.hidden = true
2230
+ failNote.textContent =
2231
+ note +
2232
+ ' You have marked things up, and a command line cannot carry notes — the whole pass is ' +
2233
+ 'below. Copy it and paste it to Claude.'
2234
+ copyOut.hidden = false
2235
+ copyOut.value = json
2236
+ copyOut.focus()
2237
+ copyOut.select()
2238
+ } else {
2239
+ failNote.textContent = note
2240
+ showFailCommand('/spec-reviewed ' + verdict)
2241
+ }
2242
+ // WHERE THE READER IS LOOKING. The verdict bar is at the foot of the page
2243
+ // and this panel is at the head of it, so a failure announced without this
2244
+ // is a failure announced off-screen — which is the shape of the bug being
2245
+ // fixed, one scroll position removed.
2246
+ if (failBox.scrollIntoView) failBox.scrollIntoView()
2247
+ }
2248
+
1618
2249
  // One action per decision: the verdict button IS the copy button, so the blob
1619
2250
  // can never carry a verdict other than the one just pressed.
1620
2251
  /**
@@ -1625,6 +2256,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1625
2256
  * much you wrote.
1626
2257
  */
1627
2258
  function post(json, verdict) {
2259
+ clearSendFailure()
1628
2260
  copyHint.hidden = false
1629
2261
  copyHint.textContent = 'Sending…'
1630
2262
  fetch(location.pathname, {
@@ -1634,11 +2266,45 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1634
2266
  }).then(function (res) {
1635
2267
  return res.text().then(function (text) {
1636
2268
  if (!res.ok) {
1637
- // A pass the server REFUSED must never read as sent. Its message
1638
- // names the entry that was wrong, so it is shown rather than summarised.
1639
- copyHint.textContent = 'Not sent — ' + (text || res.status)
2269
+ // A pass the server REFUSED must never read as sent — and WHICH
2270
+ // refusal decides where the reader goes next, so the two are named
2271
+ // separately rather than collapsed into one "not sent".
2272
+ if (res.status === 404) {
2273
+ // THE PORT MOVED, or another repo's daemon holds this one. The token
2274
+ // in this URL means nothing there, so re-opening the page from the
2275
+ // session that produced it is the fix — the link is stale, the pass
2276
+ // is fine.
2277
+ sendFailed(
2278
+ json,
2279
+ verdict,
2280
+ 'Not sent — this page\'s server is gone.',
2281
+ 'Nothing is answering at this address any more: the review server moved to another ' +
2282
+ 'port, or a different repo holds this one. Re-open the page from the Claude Code ' +
2283
+ 'session that produced it and the buttons below will send.',
2284
+ )
2285
+ } else if (res.status === 422) {
2286
+ // THE ENGINE READ IT AND SAID NO. Its message names the entry that
2287
+ // was wrong, so it is relayed verbatim — paraphrasing is how a
2288
+ // reader ends up hunting for a problem the tool had already named.
2289
+ // AND NO "re-open": the page is fine, and re-opening would only be
2290
+ // refused again.
2291
+ sendFailed(
2292
+ json,
2293
+ verdict,
2294
+ 'Not sent — the engine refused this pass.',
2295
+ text || 'It answered ' + res.status + ' without saying why.',
2296
+ )
2297
+ } else {
2298
+ sendFailed(
2299
+ json,
2300
+ verdict,
2301
+ 'Not sent — the server answered ' + res.status + '.',
2302
+ text || 'It gave no reason.',
2303
+ )
2304
+ }
1640
2305
  return
1641
2306
  }
2307
+ clearSendFailure()
1642
2308
  var code = ''
1643
2309
  try { code = JSON.parse(text).code } catch (e) {}
1644
2310
  // NAME THE COMMAND. Not "claim it with 418207", which instructs a
@@ -1648,25 +2314,194 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1648
2314
  // stays, as the thing to CHECK: it is the one part of a waiting pass
1649
2315
  // that tells yours apart from anyone else's.
1650
2316
  if (code) {
1651
- copyHint.textContent = 'Sent. Run this where Claude is:'
1652
- showCommand('/spec-reviewed ' + code)
1653
- markDecided(verdict, '/spec-reviewed ' + code)
2317
+ // SENT, AND NOW ASK WHAT BECAME OF IT. The page used to name the
2318
+ // command here unconditionally — the same sentence whether a session
2319
+ // was waiting to claim the pass or nothing was — because it had no
2320
+ // way to find out. It has one now.
2321
+ markDecided(verdict, '/spec-reviewed ' + code, null)
2322
+ watchPass(code, verdict)
1654
2323
  } else {
1655
2324
  // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
1656
2325
  // works — it picks up the single waiting pass — but the page cannot
1657
2326
  // claim the server named THIS one, so it does not put words in its
1658
2327
  // mouth. Bare is what the reader is told, because bare is what is true.
1659
2328
  copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
1660
- markDecided(verdict)
2329
+ markDecided(verdict, null, null)
1661
2330
  }
1662
2331
  })
1663
2332
  }, function () {
1664
- // The server was there when the page was served and is not now. Falling
1665
- // back to the clipboard keeps the pass recoverable rather than lost.
1666
- showFallback(json, 'Could not reach the server. Copy this, then paste it to Claude.')
2333
+ // THE REQUEST NEVER ARRIVED — the network, or a server that has stopped.
2334
+ // Distinct from both refusals above: there is no status and no message,
2335
+ // because nothing answered. Re-opening may fix it and the command
2336
+ // certainly will, so both are offered.
2337
+ sendFailed(
2338
+ json,
2339
+ verdict,
2340
+ 'Not sent — could not reach the server.',
2341
+ 'The request did not arrive: the server has stopped, or this device cannot reach it. ' +
2342
+ 'Re-open the page from the session that produced it to try again.',
2343
+ )
2344
+ })
2345
+ }
2346
+
2347
+ /* ==========================================================================
2348
+ * The command list — a verdict bar for a page that cannot deliver one
2349
+ *
2350
+ * A `file://` page has no server and no store, so its buttons never sent
2351
+ * anything: they built a JSON blob and put it on the clipboard to paste. That
2352
+ * is still the only way a pass carrying MARKS can travel, and it stays. What
2353
+ * changes is the common case — a reader who ticked nothing and wrote nothing,
2354
+ * whose whole pass is one word — where a command they run in the terminal
2355
+ * beside them beats a wall of text every time.
2356
+ *
2357
+ * WHICH SHAPE IS LIVE IS DECIDED BY WHAT THE PASS CARRIES, and it is
2358
+ * re-decided on every mark. Tick one accept and the rows stand down, because
2359
+ * a command line cannot carry someone's notes and dropping them silently
2360
+ * would be worse than asking for the paste. Clear it again and they return.
2361
+ * ======================================================================== */
2362
+
2363
+ // Only a `file://` page. A served page posts; a published one writes to its
2364
+ // store. Both can deliver, so both keep their buttons — decided from what the
2365
+ // page IS, exactly as `send` decides.
2366
+ var NO_TRANSPORT = location.protocol === 'file:' && !isArtifact()
2367
+ var cmdList = document.getElementById('cmd-list')
2368
+ var cmdLead = document.getElementById('cmd-lead')
2369
+ var cmdRows = {}
2370
+
2371
+ function buildCmdRows() {
2372
+ if (!NO_TRANSPORT || !cmdList) return
2373
+ // WHERE, not just what. "Run one of these where it is" assumed the reader
2374
+ // knew which terminal counts, and there is usually more than one open. The
2375
+ // answer is specific and worth saying: the session that produced this page
2376
+ // is the one holding the review.
2377
+ cmdLead.textContent =
2378
+ 'This page has no way to reach Claude. Copy one of these and paste it into the ' +
2379
+ 'Claude Code session that produced this review — that is the one waiting for your verdict.'
2380
+ // THE SAME LIST THE BAR USES. `OFFERED` already answers which verdicts this
2381
+ // render offers — a mid-run page lists Continue and never the committing
2382
+ // pair — so deriving the rows from it is what keeps the two from drifting.
2383
+ OFFERED.forEach(function (verdict) {
2384
+ var row = el('div', 'cmd-row')
2385
+ row.setAttribute('data-verdict', verdict)
2386
+ var label = el('span', 'cmd-label', LABELS[verdict])
2387
+ var cmd = el('code', 'cmd-text', '/spec-reviewed ' + verdict)
2388
+ row.appendChild(label)
2389
+ row.appendChild(cmd)
2390
+ // NEVER A BUTTON THAT CANNOT COPY — the same rule `showCommand` follows,
2391
+ // through the same `canCopy`. A control that does nothing on tap reads as
2392
+ // a broken page; a control that is MISSING where it would have worked
2393
+ // reads as a page that never offered it, which is the failure this shares
2394
+ // its capability check to avoid.
2395
+ if (canCopy()) {
2396
+ var copy = el('button', 'cmd-copy', 'Copy')
2397
+ copy.setAttribute('type', 'button')
2398
+ copy.addEventListener('click', function () {
2399
+ copyText(cmd.textContent, cmd).then(function () {
2400
+ // COPIED IS DECIDED. The reader has the command; what happens next
2401
+ // happens in their terminal, and the page has nothing left to do
2402
+ // but say what they chose — with the command repeated, because a
2403
+ // copy can fail silently and an hour later this box is the only
2404
+ // record of it.
2405
+ markDecided(verdict, cmd.textContent, false)
2406
+ }, function () {
2407
+ copy.textContent = selectNode(cmd) ? 'Copy failed — selected' : 'Copy failed'
2408
+ })
2409
+ })
2410
+ row.appendChild(copy)
2411
+ }
2412
+ cmdList.appendChild(row)
2413
+ cmdRows[verdict] = row
1667
2414
  })
1668
2415
  }
1669
2416
 
2417
+ /**
2418
+ * Decide, now, which of the two shapes this page is offering.
2419
+ *
2420
+ * Called from `refresh`, so it re-answers on every mark rather than once at
2421
+ * load: the reader who ticks an accept half-way through has changed what
2422
+ * their pass contains, and the page has to change with them.
2423
+ */
2424
+ function drawCmdList() {
2425
+ if (!NO_TRANSPORT || !cmdList) return
2426
+ var marked = pending() > 0
2427
+ cmdList.hidden = marked
2428
+ Object.keys(cmdRows).forEach(function (k) { cmdRows[k].hidden = marked })
2429
+ // The bar comes back to carry what a command cannot, and SAYS SO. A reader
2430
+ // whose notes had been dropped would not find out until the review came
2431
+ // back without them.
2432
+ verdictEl.hidden = !marked
2433
+ if (marked) {
2434
+ copyHint.hidden = false
2435
+ copyHint.textContent =
2436
+ 'You have marked something up, and a command line cannot carry notes — ' +
2437
+ 'use a verdict button below and paste what it copies.'
2438
+ } else if (copyHint.textContent.indexOf('cannot carry notes') !== -1) {
2439
+ copyHint.hidden = true
2440
+ copyHint.textContent = ''
2441
+ }
2442
+ }
2443
+
2444
+ // How long to keep asking, and how often. A claim is a file-watch waking a
2445
+ // session that is already sitting there, so the answer normally arrives in
2446
+ // well under a second; these are sized for the case where it does not.
2447
+ var PASS_POLL_MS = 900
2448
+ var PASS_POLL_ROUNDS = 8
2449
+
2450
+ /**
2451
+ * Ask the engine what became of the pass we just sent, and end on the answer.
2452
+ *
2453
+ * THE PAGE STOPPED GUESSING. It used to hand over `/spec-reviewed <code>` on
2454
+ * every successful POST, because it could not tell an auto-claimed pass from
2455
+ * one nobody was listening for — so the reader was told to run a command they
2456
+ * almost never needed, in the same words used when running it was the only
2457
+ * thing that would save their review. Both sentences are now earned.
2458
+ *
2459
+ * ONE ANSWER TAKES THE COMMAND AWAY, and it is the positive one: `claimed`,
2460
+ * which the engine reads off a decision log that NAMES this code. Everything
2461
+ * else — `waiting`, `unknown`, a server that never replied, a body that did
2462
+ * not parse, an older daemon with no lookup, the rounds running out — leaves
2463
+ * the command exactly where it is. A command nobody needed to run costs a
2464
+ * glance; a pass nobody claims costs the review.
2465
+ *
2466
+ * WHAT WOULD FOOL A LOOSER VERSION: treating a failed poll as "probably fine".
2467
+ * The failures that make a poll fail are the same ones that make a claim fail
2468
+ * — the machine sleeping, the daemon restarting, the network going — so the
2469
+ * moment this is least able to see is the moment its answer matters most.
2470
+ */
2471
+ function watchPass(code, verdict) {
2472
+ var rounds = 0
2473
+ // `?pass=` on the page's own path, so the serve token in the URL guards
2474
+ // this exactly as it guards everything else and there is no second address.
2475
+ var url = location.pathname + '?pass=' + encodeURIComponent(code)
2476
+
2477
+ function stop(picked) {
2478
+ markDecided(verdict, '/spec-reviewed ' + code, picked)
2479
+ }
2480
+
2481
+ function ask() {
2482
+ rounds++
2483
+ fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
2484
+ if (!res || !res.ok) return again()
2485
+ return res.text().then(function (text) {
2486
+ var state = null
2487
+ try { state = JSON.parse(text).state } catch (e) {}
2488
+ if (state === 'claimed') return stop(true)
2489
+ return again()
2490
+ })
2491
+ }, again)
2492
+ }
2493
+
2494
+ function again() {
2495
+ // Out of rounds is a CANNOT-TELL, not a no. Nobody is obliged to claim a
2496
+ // pass inside seven seconds, and a page that concluded "abandoned" would
2497
+ // be inventing an answer out of its own impatience.
2498
+ if (rounds >= PASS_POLL_ROUNDS) return stop(false)
2499
+ window.setTimeout(ask, PASS_POLL_MS)
2500
+ }
2501
+
2502
+ ask()
2503
+ }
2504
+
1670
2505
  /**
1671
2506
  * Is this page running as a published Artifact?
1672
2507
  *
@@ -1714,7 +2549,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1714
2549
  // the one thing that is true and actionable.
1715
2550
  copyHint.textContent = 'Sent. Run this where Claude is:'
1716
2551
  showCommand('/spec-reviewed')
1717
- markDecided(verdict, '/spec-reviewed')
2552
+ // A PUBLISHED PAGE CANNOT ASK. The pass went into the artifact's own
2553
+ // store, which the engine only reads when someone tells it to, so
2554
+ // nothing here could ever come back `true` — and the command is the
2555
+ // whole ending rather than a fallback within it.
2556
+ markDecided(verdict, '/spec-reviewed', null)
1718
2557
  })
1719
2558
  }).then(null, function (err) {
1720
2559
  if (landed) return
@@ -1762,6 +2601,186 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1762
2601
  })
1763
2602
  }
1764
2603
 
2604
+ /**
2605
+ * The surfaces strip: where this review lives, and whether it is also running.
2606
+ *
2607
+ * AN ACTION IS NOT A VERDICT, and the whole strip exists to make that visible.
2608
+ * A verdict is the reader's conclusion, consumed once. These change what is
2609
+ * RUNNING (or which tiers are permitted) and hand the reader straight back to
2610
+ * the same page with the same options — so pressing one must not close the
2611
+ * page into its decided state, and must not clear a gate.
2612
+ *
2613
+ * ENABLE-ONLY for the tiers. Turning `network` off from a page reached over
2614
+ * the network kills the page doing the turning, and the reader gains nothing
2615
+ * they could not get by typing the command — so the engine's `tierAction`
2616
+ * never offers a disable and this renders whatever it is given.
2617
+ */
2618
+ var ACTION_LABEL = {
2619
+ // NAMES THE COMMIT IT PERFORMS. `live take` refuses a dirty worktree, so
2620
+ // the action commits the phase before taking the instance — and a press
2621
+ // that commits behind a label reading only "put it live" is a side effect
2622
+ // nobody agreed to. The reader has still approved nothing: this is a
2623
+ // precondition of the thing they asked for, not a verdict on the diff.
2624
+ 'live-on': '▶ Commit & put it live',
2625
+ 'allow-network': '▶ Allow network',
2626
+ 'allow-remote': '▶ Allow remote',
2627
+ }
2628
+ var ACTION_CMD = {
2629
+ 'live-on': '/spec-live',
2630
+ 'allow-network': 'skitterspec spec-env review allow network',
2631
+ 'allow-remote': '/spec-remote-review',
2632
+ }
2633
+ var SURFACE_LABEL = { live: 'live', network: 'network', remote: 'remote' }
2634
+
2635
+ function surfaceStateText(row) {
2636
+ if (row.kind === 'live') {
2637
+ if (row.state === 'on') return row.url ? 'on — running at ' + row.url : 'on'
2638
+ if (row.state === 'held') return row.reason || 'held by another spec'
2639
+ return 'off'
2640
+ }
2641
+ if (row.state === 'on') return row.url ? 'on — ' + row.url : 'on'
2642
+ return row.state === 'off' ? 'off' : 'unavailable'
2643
+ }
2644
+
2645
+ function buildSurfaces() {
2646
+ var section = document.getElementById('surfaces')
2647
+ var host = document.getElementById('surface-rows')
2648
+ // ABSENT, NOT EMPTY. A render that carried no surfaces gets no strip and no
2649
+ // line explaining the absence — a `--docs` page has no branch to put live,
2650
+ // and that is a healthy page rather than a deprived one.
2651
+ if (!section || !host || !data.surfaces || !data.surfaces.length) return
2652
+ section.hidden = false
2653
+ data.surfaces.forEach(function (row) {
2654
+ var div = document.createElement('div')
2655
+ div.className = 'surface-row'
2656
+ var label = document.createElement('span')
2657
+ label.className = 'surface-label'
2658
+ label.textContent = SURFACE_LABEL[row.kind === 'live' ? 'live' : row.tier] || ''
2659
+ div.appendChild(label)
2660
+ var state = document.createElement('span')
2661
+ state.className = 'surface-state'
2662
+ state.textContent = surfaceStateText(row)
2663
+ div.appendChild(state)
2664
+ if (row.action) {
2665
+ if (NO_TRANSPORT) {
2666
+ // No server and no store, so a button here would build an instruction
2667
+ // with nowhere to go. The command is the honest control — exactly the
2668
+ // trade the verdict bar already makes on a `file://` page.
2669
+ var cmd = document.createElement('code')
2670
+ cmd.className = 'surface-cmd'
2671
+ cmd.textContent = ACTION_CMD[row.action] || ''
2672
+ div.appendChild(cmd)
2673
+ } else {
2674
+ var btn = document.createElement('button')
2675
+ btn.type = 'button'
2676
+ btn.textContent = ACTION_LABEL[row.action] || row.action
2677
+ btn.setAttribute('data-action', row.action)
2678
+ btn.addEventListener('click', function () {
2679
+ if (btn.disabled) return
2680
+ sendAction(row.action, btn)
2681
+ })
2682
+ div.appendChild(btn)
2683
+ }
2684
+ }
2685
+ // THE COMMAND FOR A DIRECTION THE PAGE DOES NOT OFFER. A live spec shows
2686
+ // no press — handing the instance back to `main` is a workspace decision,
2687
+ // not this review's — so the line names `/spec-live main` instead, and the
2688
+ // reader is never left knowing what they want and not what to type.
2689
+ if (row.command && !row.action) {
2690
+ var cmdOnly = document.createElement('code')
2691
+ cmdOnly.className = 'surface-cmd'
2692
+ cmdOnly.textContent = row.command
2693
+ div.appendChild(cmdOnly)
2694
+ }
2695
+ if (row.note) {
2696
+ var note = document.createElement('span')
2697
+ note.className = 'surface-note'
2698
+ note.textContent = row.note
2699
+ div.appendChild(note)
2700
+ }
2701
+ host.appendChild(div)
2702
+ })
2703
+ }
2704
+
2705
+ /**
2706
+ * Send an action, and leave the page exactly as live as it was.
2707
+ *
2708
+ * IT CARRIES NO MARKS. An action blob is an instruction, not a review pass —
2709
+ * so the accepts and comments stay on the page and travel with the verdict
2710
+ * the reader eventually chooses. Sending them here would merge them early and
2711
+ * then send them again, which is one reader's review counted twice.
2712
+ *
2713
+ * AND IT NEVER CALLS `markDecided`. The reader still owes a verdict; closing
2714
+ * the page over an action would be the record-and-do-nothing ending this
2715
+ * whole contract is against.
2716
+ */
2717
+ function sendAction(action, btn) {
2718
+ var blob = {
2719
+ version: 1,
2720
+ spec: data.spec,
2721
+ generatedAt: data.generatedAt,
2722
+ accepted: [],
2723
+ unaccepted: [],
2724
+ comments: [],
2725
+ action: action,
2726
+ }
2727
+ btn.disabled = true
2728
+ var was = btn.textContent
2729
+ btn.textContent = 'Sending…'
2730
+ fetch(location.pathname, {
2731
+ method: 'POST',
2732
+ headers: { 'content-type': 'application/json' },
2733
+ body: JSON.stringify(blob),
2734
+ }).then(function (res) {
2735
+ if (!res || !res.ok) {
2736
+ btn.disabled = false
2737
+ btn.textContent = was
2738
+ return
2739
+ }
2740
+ return res.text().then(function (text) {
2741
+ var code = null
2742
+ try { code = JSON.parse(text).code } catch (e) {}
2743
+ btn.textContent = 'Sent'
2744
+ // RELOAD WHEN IT HAS BEEN ACTED ON, not on a timer. The agent re-renders
2745
+ // after the action, so the page the reader wants is the next one — and
2746
+ // `?pass=` already answers whether this exact code was claimed. Running
2747
+ // out of rounds leaves the page alone rather than reloading on a guess.
2748
+ if (code) watchAction(code, btn)
2749
+ })
2750
+ }, function () {
2751
+ btn.disabled = false
2752
+ btn.textContent = was
2753
+ })
2754
+ }
2755
+
2756
+ function watchAction(code, btn) {
2757
+ var rounds = 0
2758
+ var url = location.pathname + '?pass=' + encodeURIComponent(code)
2759
+ function ask() {
2760
+ rounds++
2761
+ fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
2762
+ if (!res || !res.ok) return again()
2763
+ return res.text().then(function (text) {
2764
+ var state = null
2765
+ try { state = JSON.parse(text).state } catch (e) {}
2766
+ if (state === 'claimed') return window.location.reload()
2767
+ return again()
2768
+ })
2769
+ }, again)
2770
+ }
2771
+ function again() {
2772
+ // CANNOT TELL, so change nothing. Nobody is obliged to act on an action
2773
+ // inside a few seconds, and a page that reloaded out of impatience would
2774
+ // throw away marks the reader had not sent yet.
2775
+ if (rounds >= PASS_POLL_ROUNDS) {
2776
+ btn.textContent = 'Sent — reload when it is done'
2777
+ return
2778
+ }
2779
+ window.setTimeout(ask, PASS_POLL_MS)
2780
+ }
2781
+ ask()
2782
+ }
2783
+
1765
2784
  Object.keys(verdictBtns).forEach(function (verdict) {
1766
2785
  verdictBtns[verdict].addEventListener('click', function () {
1767
2786
  // A disabled or hidden button does not fire in a browser; the guard is
@@ -1788,10 +2807,13 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1788
2807
  }
1789
2808
 
1790
2809
  restore()
2810
+ buildCmdRows()
2811
+ buildSurfaces()
1791
2812
  drawContext()
1792
2813
  drawLog()
1793
2814
  drawDecided()
1794
2815
  wireChecks()
2816
+ wireGotos()
1795
2817
  state.forEach(function (st) {
1796
2818
  var btn = st.details && st.details.querySelector('.accept')
1797
2819
  if (btn) btn.setAttribute('aria-pressed', String(st.accept))
@@ -1813,7 +2835,54 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1813
2835
  })
1814
2836
 
1815
2837
  // --- header --------------------------------------------------------------
1816
- document.getElementById('title').textContent = data.title
2838
+ var titleEl = document.getElementById('title')
2839
+ titleEl.textContent = data.title
2840
+ /**
2841
+ * The tracker ticket, beside the title and linked where there is a url.
2842
+ *
2843
+ * `href` IS SET ONLY FROM A URL THE ENGINE ALREADY VETTED as http(s) — see
2844
+ * `readTicket`. The value originates in a spec file someone edits, and this
2845
+ * page is served over the network, so a `javascript:` url in an href would be
2846
+ * script execution. An id with no usable url still renders; it just does not
2847
+ * become a link, which is the harmless half of the pair.
2848
+ */
2849
+ ;(function () {
2850
+ var tk = data.context && data.context.ticket
2851
+ if (!tk || !tk.id) return
2852
+ var node
2853
+ if (tk.url) {
2854
+ node = document.createElement('a')
2855
+ node.setAttribute('href', tk.url)
2856
+ node.setAttribute('target', '_blank')
2857
+ node.setAttribute('rel', 'noopener noreferrer')
2858
+ } else {
2859
+ node = document.createElement('span')
2860
+ }
2861
+ node.className = 'ticket'
2862
+ node.textContent = tk.id
2863
+ titleEl.appendChild(document.createTextNode(' '))
2864
+ titleEl.appendChild(node)
2865
+ })()
2866
+
2867
+ /**
2868
+ * An ISO timestamp is a machine's format. The header showed
2869
+ * `2026-09-16T13:53:58.370Z` to a person deciding whether a page was stale,
2870
+ * which is the one question it is there to answer and the hardest way to ask
2871
+ * it. Rendered in the READER's locale and zone, because that is the clock
2872
+ * they are comparing against; the raw value stays as the `title` so nothing
2873
+ * precise is lost, and it survives a browser that throws on either call.
2874
+ */
2875
+ function whenRendered(iso) {
2876
+ try {
2877
+ var d = new Date(iso)
2878
+ if (isNaN(d.getTime())) return iso
2879
+ return d.toLocaleString(undefined, {
2880
+ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
2881
+ })
2882
+ } catch (e) {
2883
+ return iso
2884
+ }
2885
+ }
1817
2886
  var t = data.totals
1818
2887
  var sub = document.getElementById('sub')
1819
2888
  function part(text, cls) { sub.appendChild(el('span', cls, text)) }
@@ -1822,7 +2891,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1822
2891
  sep()
1823
2892
  part(data.mode === 'branch'
1824
2893
  ? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
1825
- : 'uncommitted work')
2894
+ : data.mode === 'docs'
2895
+ ? "the spec's own documents"
2896
+ : 'uncommitted work')
1826
2897
  sep()
1827
2898
  part(t.files + ' file' + (t.files === 1 ? '' : 's'))
1828
2899
  sep()
@@ -1830,7 +2901,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1830
2901
  part(' ')
1831
2902
  part('-' + t.deletions, 'd')
1832
2903
  sep()
1833
- part(data.generatedAt)
2904
+ var when = el('span', null, whenRendered(data.generatedAt))
2905
+ when.setAttribute('title', data.generatedAt)
2906
+ sub.appendChild(when)
1834
2907
  })()
1835
2908
  </script>
1836
2909
  </body>