@skitterbyte/skitterspec-linear 15.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 (44) hide show
  1. package/MIGRATION.md +218 -0
  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 +100 -23
  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 +1044 -101
  11. package/assets/rules/spec-planning.md +35 -3
  12. package/assets/rules/spec-reports.md +131 -20
  13. package/assets/skills/spec/SKILL.md +161 -4
  14. package/assets/skills/spec-bug/SKILL.md +68 -30
  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 +131 -36
  19. package/assets/skills/spec-hotfix/SKILL.md +61 -25
  20. package/assets/skills/spec-linear-setup/SKILL.md +19 -11
  21. package/assets/skills/spec-next/SKILL.md +121 -58
  22. package/assets/skills/spec-push/SKILL.md +45 -0
  23. package/assets/skills/spec-review/SKILL.md +89 -2
  24. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  25. package/assets/skills/spec-start/SKILL.md +26 -3
  26. package/assets/skills/spec-status/SKILL.md +20 -6
  27. package/assets/skills/spec-sync/SKILL.md +1 -0
  28. package/package.json +2 -2
  29. package/src/cli.js +913 -116
  30. package/src/env/classify.js +87 -2
  31. package/src/env/config.js +214 -17
  32. package/src/env/live.js +94 -0
  33. package/src/env/resolve.js +36 -2
  34. package/src/env/review.js +542 -21
  35. package/src/env/serve.js +298 -19
  36. package/src/env/supervise.js +8 -1
  37. package/src/init.js +60 -9
  38. package/src/vendor/linear/api.js +111 -2
  39. package/src/vendor/linear/cli-sync.js +661 -11
  40. package/src/vendor/linear/config.js +41 -13
  41. package/src/vendor/linear/doctor.js +6 -5
  42. package/src/vendor/sync-core/index.js +11 -3
  43. package/src/vendor/sync-core/src/compare.js +65 -0
  44. package/src/vendor/sync-core/src/normalize.js +26 -0
@@ -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 {
@@ -382,6 +415,28 @@ tr.note-row td {
382
415
  border-radius: 10px;
383
416
  background: var(--panel);
384
417
  }
418
+ /* WHERE THIS REVIEW LIVES, and whether it is also running. It sits ABOVE the
419
+ verdicts because the question it answers — do I want to look at this running
420
+ before I conclude? — comes before the conclusion, not after it. Visually its
421
+ own strip, because it is not a verdict: pressing one of these changes what is
422
+ running (or which surfaces are permitted) and hands the reader straight back
423
+ to the same page with the same options. */
424
+ .surfaces {
425
+ margin: 1.2rem 0 0;
426
+ padding: .55rem .7rem;
427
+ border: 1px solid var(--line);
428
+ border-radius: 10px;
429
+ background: var(--panel);
430
+ }
431
+ .surfaces[hidden] { display: none; }
432
+ .surfaces-title { display: block; font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; }
433
+ .surface-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .3rem 0; }
434
+ .surface-label { font-size: .8rem; font-weight: 600; min-width: 4.5rem; }
435
+ .surface-state { font-size: .8rem; color: var(--muted); }
436
+ .surface-note { font-size: .78rem; color: var(--muted); }
437
+ /* The command, for a page with no transport. Same treatment as the verdict
438
+ command list: plain text a single tap selects whole, never an input. */
439
+ .surface-cmd { font-size: .78rem; user-select: all; }
385
440
  .verdict-end { margin: 1.2rem 0 0; }
386
441
  .verdict-title { font-size: .8rem; color: var(--muted); }
387
442
  .verdict-count { font-size: .8rem; color: var(--muted); }
@@ -393,6 +448,13 @@ tr.note-row td {
393
448
  /* A button the render did not ask for is GONE, not dimmed — a disabled control
394
449
  invites a reader to work out how to enable it, and there is nothing to do. */
395
450
  .verdict button[hidden] { display: none; }
451
+ /* AND THE BAR ITSELF. `display: flex` beats the browser's `[hidden]`, so the
452
+ whole bar stayed on screen on a `file://` page — offering four buttons that
453
+ POST to a server that is not there, directly above the command list that
454
+ exists because they cannot. The guard in `assets-review` only read the
455
+ template for a `hidden` attribute, and this element is hidden at runtime, so
456
+ it never saw it; the guard reads the script now too. */
457
+ .verdict[hidden] { display: none; }
396
458
  /* A blocked control is dashed as well as dimmed, so the block survives a
397
459
  greyscale screen — and the reason rides in the label, never in a tooltip
398
460
  nobody hovers on a phone. */
@@ -448,7 +510,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
448
510
  .decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
449
511
  .decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
450
512
  .decided-note:last-child { margin-bottom: 0; }
451
- .decided-toggle { font-size: .78rem; }
513
+ /* SPACE ABOVE IT. The toggle sits under whatever the panel ended on — a
514
+ sentence, or the command callout — and butted straight against the callout's
515
+ border it read as part of it rather than as the way back to the diff. */
516
+ .decided-toggle { font-size: .78rem; margin-top: .8rem; }
452
517
 
453
518
  /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
454
519
  to be read rather than data to be scanned, so it gets a measure, air, and a
@@ -506,6 +571,17 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
506
571
  .context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
507
572
  .context-more[open] > summary { margin-bottom: .6rem; }
508
573
  .context li.done { color: var(--muted); }
574
+ /* The ticket rides ON the title line rather than under it — it is the other
575
+ name for this work, not a fact about it. Sized down and tinted so the title
576
+ still reads as the title. */
577
+ .ticket {
578
+ font-size: .62em; font-weight: 600; vertical-align: middle;
579
+ font-family: var(--mono, ui-monospace, monospace);
580
+ color: var(--accent); text-decoration: none;
581
+ padding: .12em .45em; border: 1px solid var(--line); border-radius: 999px;
582
+ white-space: nowrap;
583
+ }
584
+ a.ticket:hover { border-color: var(--accent); }
509
585
  .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
510
586
  .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
511
587
  .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
@@ -518,8 +594,100 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
518
594
  flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
519
595
  font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
520
596
  background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
597
+ /* One tap takes the whole command — the gesture that still works on a page
598
+ served over plain http, where the clipboard API is withheld. */
599
+ user-select: all; -webkit-user-select: all;
600
+ overflow-x: auto; white-space: pre; line-height: 1.6;
521
601
  }
522
602
  .sent-cmd-copy { white-space: nowrap; }
603
+ /* ONE GRID FOR THE WHOLE LIST, so the command column starts at the same x on
604
+ every row. A per-row layout cannot do that: each row would size its own label
605
+ column to its own label, and `Commit` and `Commit & Continue` are not the
606
+ same width. The label column is `max-content` — the longest label decides,
607
+ which is a fact about the labels rather than a rem value someone has to
608
+ remember to update when one changes. */
609
+ .cmd-list { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; margin: .6rem 0 0; }
610
+ .cmd-list[hidden] { display: none; }
611
+ .cmd-lead { grid-column: 1 / -1; font-size: .85rem; color: var(--muted); margin: 0 0 .5rem; }
612
+ /* ONE LINE: label, command, Copy. It was three lines deep — the label on its
613
+ own row above the command — which spent a screenful on four rows that fit on
614
+ four lines. */
615
+ .cmd-row {
616
+ display: grid;
617
+ grid-column: 1 / -1;
618
+ /* FALLBACK FIRST, then subgrid. A browser without subgrid drops the second
619
+ declaration and keeps this one, which still gives one line per row — just
620
+ each sized to itself. Without a fallback the row would fall back to a
621
+ single auto column and stack, which is worse than the misalignment. */
622
+ grid-template-columns: max-content minmax(0, 1fr) max-content;
623
+ grid-template-columns: subgrid;
624
+ gap: .5rem; align-items: center;
625
+ padding: .4rem .5rem; margin-bottom: .35rem;
626
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
627
+ }
628
+ .cmd-row[hidden] { display: none; }
629
+ .cmd-label { font-weight: 600; font-size: .85rem; white-space: nowrap; }
630
+ /* A TINTED EDGE, NOT A TINTED BOX. Four filled rows read as four warnings —
631
+ the colour shouted where it only needed to identify. The left border and the
632
+ label carry it now, which is enough to recognise the choice by and quiet
633
+ enough to read past. No new tokens: `v-commit` and `v-changes` above borrow
634
+ exactly this pair. */
635
+ .cmd-row[data-verdict="commit"],
636
+ .cmd-row[data-verdict="commit-start"],
637
+ .cmd-row[data-verdict="commit-continue"],
638
+ .cmd-row[data-verdict="continue"] { border-left: 3px solid var(--good-fg); }
639
+ .cmd-row[data-verdict="commit"] .cmd-label,
640
+ .cmd-row[data-verdict="commit-start"] .cmd-label,
641
+ .cmd-row[data-verdict="commit-continue"] .cmd-label,
642
+ .cmd-row[data-verdict="continue"] .cmd-label { color: var(--good-fg); }
643
+ .cmd-row[data-verdict="changes"] { border-left: 3px solid var(--flag-fg); }
644
+ .cmd-row[data-verdict="changes"] .cmd-label { color: var(--flag-fg); }
645
+ /* Discuss keeps the neutral panel, exactly as its button does — it is the
646
+ ending that decides nothing, and colouring it would say otherwise. */
647
+ .cmd-text {
648
+ /* No `flex` — the list's grid column sizes this now. min-width:0 still
649
+ earns its place: it is what lets a long command scroll inside its own box
650
+ instead of widening the column and pushing Copy off the row. */
651
+ min-width: 0; font-family: var(--mono, ui-monospace, monospace);
652
+ font-size: .85rem; padding: .3rem .45rem; color: var(--fg);
653
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px;
654
+ /* One tap takes the whole command — see `.sent-cmd-text`. */
655
+ user-select: all; -webkit-user-select: all;
656
+ overflow-x: auto; white-space: pre; line-height: 1.6;
657
+ }
658
+ .cmd-copy { white-space: nowrap; }
659
+ /* AN ACTION, NOT A NOTE. The command reached the reader as a grey line under a
660
+ bar that had just closed, and it read as a footnote to something finished —
661
+ so it is now a filled callout inside the decided panel, which is the block a
662
+ re-opened page shows first. It reuses the add palette rather than minting a
663
+ green of its own: those three tokens are already defined on bare `:root` and
664
+ redefined in both theme blocks, which is the property `assets-review` guards. */
665
+ .sent-cmd.act {
666
+ flex-wrap: wrap; align-items: center; margin-top: .7rem;
667
+ padding: .55rem .6rem; border-radius: 6px;
668
+ background: var(--add-bg); border: 1px solid var(--add-mark); color: var(--add-fg);
669
+ }
670
+ .sent-cmd-lead { flex-basis: 100%; font-size: .85rem; font-weight: 600; color: var(--add-fg); }
671
+ /* A FAILED SEND, IN THE PALETTE OF A FAILURE. The same callout the waiting-pass
672
+ case uses, in the delete colours rather than the add ones — `--del-bg` /
673
+ `--del-fg` / `--del-mark` are already defined on bare `:root` and redefined in
674
+ both theme blocks, which is the property `assets-review` guards. No new token
675
+ is minted for this. */
676
+ .sent-cmd.act.fail {
677
+ background: var(--del-bg); border-color: var(--del-mark); color: var(--del-fg);
678
+ }
679
+ .sent-cmd.act.fail .sent-cmd-lead { color: var(--del-fg); }
680
+ /* THE OUTCOME THE READER MUST ACT ON, AS LOUD AS THE ONE THEY NEED NOT.
681
+ A failed send used to be a small grey line in the footer, under a verdict bar
682
+ that had just closed — quieter than the success it was not. It gets the panel
683
+ the decided page gets, at the top of the column, in the delete palette. What
684
+ it does NOT get is `is-decided`: nothing was delivered, so the diff stays and
685
+ the verdict buttons stay live for a second try. */
686
+ .decided.failed {
687
+ border-left-color: var(--del-mark);
688
+ background: var(--panel);
689
+ }
690
+ .decided.failed .decided-what { color: var(--del-mark); }
523
691
  /* The claim code is read aloud or copied, so it is set apart from the sentence
524
692
  around it rather than buried in it. */
525
693
  .copy-hint { user-select: text; }
@@ -548,12 +716,44 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
548
716
  engine from the spec's own files — nothing here passed through the
549
717
  model, so it is free however large the diff. Absent entirely for a spec
550
718
  whose overview cannot be read: the page worked without it before. -->
719
+ <!-- A SEND THAT DID NOT LAND. Same shape as the panel below, delete palette,
720
+ and deliberately NOT the same section: this one leaves the page
721
+ undecided, because nothing was delivered and the reader still has a
722
+ verdict to give. It names which failure happened, since a server that
723
+ has moved and a pass the engine refused send the reader to different
724
+ places. -->
725
+ <section class="decided failed" id="send-failed" hidden>
726
+ <p class="decided-what" id="send-failed-what"></p>
727
+ <p class="decided-note" id="send-failed-note"></p>
728
+ <p class="sent-cmd act fail" id="send-failed-cmd" hidden>
729
+ <span class="sent-cmd-lead" id="send-failed-lead"></span>
730
+ <code class="sent-cmd-text" id="send-failed-text"></code>
731
+ <button type="button" class="sent-cmd-copy" id="send-failed-copy" hidden>Copy</button>
732
+ </p>
733
+ </section>
551
734
  <!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
552
735
  above everything because on a re-open it is the only thing you need:
553
736
  the review is over, and this says how it ended. -->
554
737
  <section class="decided" id="decided" hidden>
555
738
  <p class="decided-what" id="decided-what"></p>
556
739
  <p class="decided-note" id="decided-note"></p>
740
+ <!-- THE ONE THING LEFT TO DO, and it lives HERE rather than under the
741
+ verdict bar because that is where it was being missed: a grey line
742
+ below a bar that had just closed, in the type size of a footnote,
743
+ saying the same words whether it mattered or not. It is shown only
744
+ where the page has ESTABLISHED that the pass is still sitting there,
745
+ so when it appears it is always an instruction. -->
746
+ <p class="sent-cmd act" id="sent-cmd" hidden>
747
+ <span class="sent-cmd-lead" id="sent-cmd-lead"></span>
748
+ <!-- PLAIN TEXT, NOT AN INPUT. A readonly field still looks like
749
+ something to type into, and on a phone tapping it raises the
750
+ keyboard for a value nobody can change. It is a command to read and
751
+ copy, so it is marked up as one — `user-select: all` makes a single
752
+ tap take the whole thing, which is the gesture that works where no
753
+ clipboard API does. -->
754
+ <code class="sent-cmd-text" id="sent-cmd-text"></code>
755
+ <button type="button" class="sent-cmd-copy" id="sent-cmd-copy" hidden>Copy</button>
756
+ </p>
557
757
  <button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
558
758
  </section>
559
759
  <section class="context reviewable" id="context" hidden>
@@ -565,6 +765,13 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
565
765
  </section>
566
766
  <div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
567
767
  <div id="files" class="reviewable"></div>
768
+ <!-- THE SURFACES STRIP. Built by the script from `data.surfaces`, and
769
+ absent entirely when the render carried none — a `--docs` page has no
770
+ branch to put live, so it gets no strip and no explanation of why. -->
771
+ <section class="surfaces reviewable" id="surfaces" hidden>
772
+ <span class="surfaces-title">Where this runs</span>
773
+ <div id="surface-rows"></div>
774
+ </section>
568
775
  <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
569
776
  header and the first person to use it could not find it: you read a
570
777
  350-line diff downward, and the control asking for your conclusion was
@@ -582,16 +789,30 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
582
789
  read it, carry on. It is not the removed `none` verdict — that
583
790
  recorded itself and did nothing, while this one resumes the run. -->
584
791
  <button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
792
+ <!-- THE AUTHORING VERDICT, hidden unless the render asked for it. A spec
793
+ that has just been written has no phase in flight to continue, so
794
+ "Commit & Continue" is the wrong verb for it: this one commits the
795
+ spec and puts it IN FLIGHT, which is the `commit && /spec-start`
796
+ that was typed by hand until now. -->
797
+ <button id="verdict-commit-start" class="v-commit" type="button" hidden>&#10003; Commit &amp; Start</button>
585
798
  <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
586
799
  <button id="verdict-discuss" type="button">… Discuss first</button>
587
800
  <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
588
801
  </div>
802
+ <!-- WHERE THE BUTTONS CANNOT DELIVER, the commands stand in their place.
803
+ A `file://` page has no server to POST to and no store to write to,
804
+ so a verdict button there only ever built a blob for the reader to
805
+ paste — a wall of JSON standing in for one word. Each row is named
806
+ with the BUTTON'S own label, because that is the word the reader
807
+ chose everywhere else; `commit-continue` is the wire spelling, and
808
+ nobody should have to learn a second vocabulary to recognise their
809
+ own decision. Built by the script, so there is exactly one list of
810
+ verdicts and it is the one the bar already uses. -->
811
+ <div class="cmd-list" id="cmd-list" hidden>
812
+ <p class="cmd-lead" id="cmd-lead"></p>
813
+ </div>
589
814
  <p class="verdict-log" id="verdict-log" hidden></p>
590
815
  <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
816
  <textarea class="copy-out" id="copy-out" readonly hidden aria-label="Review blob to copy"></textarea>
596
817
  <p class="drawn-by" id="drawn-by"></p>
597
818
  </footer>
@@ -1089,28 +1310,56 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1089
1310
  d.scrollIntoView({ block: 'start' })
1090
1311
  }
1091
1312
 
1313
+ // ONE FLAT LIST, indented by per-row padding rather than by nesting <ul>s.
1314
+ //
1315
+ // That is not a tidy-up: the change counts are `position: sticky; right: 0`,
1316
+ // and a sticky box can only pin within its own row. Nested <ul>s make each
1317
+ // level narrower than its parent, so a deep row ends before the scrollport's
1318
+ // right edge and its counts slide away the moment you scroll sideways. Flat
1319
+ // rows all share the widest row's width, so every one of them has something
1320
+ // to pin to.
1321
+ //
1322
+ // Depth goes on as an inline style because it is data, not a class: the tree
1323
+ // is however deep the diff happens to be, and a rule per level would be a
1324
+ // guess at the maximum.
1325
+ function indent(node, depth, base) {
1326
+ // Rounded, because 3 * 0.8 is 2.4000000000000004 in binary floating point
1327
+ // and that lands verbatim in the attribute. CSS copes; a person reading the
1328
+ // page source does not, and neither does a test asserting the value.
1329
+ var rem = Math.round((base + depth * 0.8) * 100) / 100
1330
+ node.setAttribute('style', 'padding-left:' + rem + 'rem')
1331
+ return node
1332
+ }
1333
+
1092
1334
  function renderTree(node) {
1093
1335
  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
- })
1336
+ ;(function walk(n, depth) {
1337
+ Object.keys(n.dirs).sort().forEach(function (name) {
1338
+ var li = el('li')
1339
+ li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
1340
+ ul.appendChild(li)
1341
+ // The subtree goes in AFTER its own label and before this level's
1342
+ // files, which is the order the nested form produced.
1343
+ walk(n.dirs[name], depth + 1)
1344
+ })
1345
+ n.files.sort(function (a, b) { return a.name.localeCompare(b.name) }).forEach(function (entry) {
1346
+ var li = el('li')
1347
+ var b = el('button', 'tree-file' + (entry.file.noise ? ' is-noise' : ''))
1348
+ b.type = 'button'
1349
+ // The same .6rem base as a dir label, so a file sits level with the
1350
+ // directory it is in. It is on the name rather than the row because the
1351
+ // row must reach the scrollport edge — see `.tree-file` padding.
1352
+ b.appendChild(indent(el('span', 'nm', entry.name), depth, 0.6))
1353
+ var ct = el('span', 'ct')
1354
+ ct.appendChild(el('span', 'a', '+' + entry.file.additions))
1355
+ ct.appendChild(document.createTextNode(' '))
1356
+ ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
1357
+ b.appendChild(ct)
1358
+ b.addEventListener('click', function () { reveal(entry.file) })
1359
+ li.appendChild(b)
1360
+ ul.appendChild(li)
1361
+ })
1362
+ })(node, 0)
1114
1363
  return ul
1115
1364
  }
1116
1365
 
@@ -1228,6 +1477,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1228
1477
  var verdictBtns = {
1229
1478
  commit: document.getElementById('verdict-commit'),
1230
1479
  'commit-continue': document.getElementById('verdict-commit-continue'),
1480
+ 'commit-start': document.getElementById('verdict-commit-start'),
1231
1481
  continue: document.getElementById('verdict-continue'),
1232
1482
  changes: document.getElementById('verdict-changes'),
1233
1483
  discuss: document.getElementById('verdict-discuss'),
@@ -1235,7 +1485,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1235
1485
  // The committing pair, named once. A fifth verdict that commits is added
1236
1486
  // here, and the shared block below follows for free — the same reason the
1237
1487
  // engine keeps a `COMMITTING` list rather than a second condition.
1238
- var COMMITTERS = ['commit', 'commit-continue']
1488
+ var COMMITTERS = ['commit', 'commit-continue', 'commit-start']
1239
1489
 
1240
1490
  /*
1241
1491
  * WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
@@ -1250,7 +1500,15 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1250
1500
  * changes.
1251
1501
  */
1252
1502
  var MIDRUN = data.buttons === 'midrun'
1253
- var OFFERED = MIDRUN ? ['continue', 'changes', 'discuss'] : ['commit', 'commit-continue', 'changes', 'discuss']
1503
+ // ONE TABLE, keyed by the set the engine named. A chain of ternaries is how a
1504
+ // third set becomes a fourth condition somebody forgets to add a button to.
1505
+ var OFFERS = {
1506
+ midrun: ['continue', 'changes', 'discuss'],
1507
+ authoring: ['commit-start', 'commit', 'changes', 'discuss'],
1508
+ refresh: ['commit', 'changes', 'discuss'],
1509
+ committing: ['commit', 'commit-continue', 'changes', 'discuss'],
1510
+ }
1511
+ var OFFERED = OFFERS[data.buttons] || OFFERS.committing
1254
1512
  Object.keys(verdictBtns).forEach(function (key) {
1255
1513
  verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
1256
1514
  })
@@ -1263,6 +1521,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1263
1521
  var LABELS = {
1264
1522
  commit: '✓ Commit',
1265
1523
  'commit-continue': '✓ Commit & Continue',
1524
+ 'commit-start': '✓ Commit & Start',
1266
1525
  continue: '→ Continue',
1267
1526
  changes: '↺ Request changes',
1268
1527
  discuss: '… Discuss first',
@@ -1272,11 +1531,15 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1272
1531
  // Says what it will NOT do: a reader must not press this expecting the
1273
1532
  // spec to be finished and landed.
1274
1533
  'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
1534
+ // Says what it WILL do, because this is the one verdict that provisions:
1535
+ // a worktree appears and phase 1 starts.
1536
+ 'commit-start': 'Commit this spec, then put it in flight and build phase 1',
1275
1537
  // Says what it will NOT do, for the same reason: nothing is committed by
1276
1538
  // this, so the work it resumes is still uncommitted afterwards.
1277
1539
  continue: 'You have read it — carry on with the run; nothing is committed',
1278
1540
  }
1279
1541
 
1542
+ var verdictEl = document.getElementById('verdict')
1280
1543
  var verdictCount = document.getElementById('verdict-count')
1281
1544
  var verdictLog = document.getElementById('verdict-log')
1282
1545
  var copyOut = document.getElementById('copy-out')
@@ -1289,6 +1552,14 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1289
1552
  var sentCmd = document.getElementById('sent-cmd')
1290
1553
  var sentCmdText = document.getElementById('sent-cmd-text')
1291
1554
  var sentCmdCopy = document.getElementById('sent-cmd-copy')
1555
+ var sentCmdLead = document.getElementById('sent-cmd-lead')
1556
+ var failBox = document.getElementById('send-failed')
1557
+ var failWhat = document.getElementById('send-failed-what')
1558
+ var failNote = document.getElementById('send-failed-note')
1559
+ var failCmd = document.getElementById('send-failed-cmd')
1560
+ var failLead = document.getElementById('send-failed-lead')
1561
+ var failText = document.getElementById('send-failed-text')
1562
+ var failCopy = document.getElementById('send-failed-copy')
1292
1563
 
1293
1564
  function pending() {
1294
1565
  var b = buildBlob()
@@ -1320,6 +1591,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1320
1591
  // the two committing buttons unconditionally, so without this it would undo
1321
1592
  // the ending every time anything redrew.
1322
1593
  if (decided) return drawDecided()
1594
+ // Which shape this page offers is re-answered here rather than at load: a
1595
+ // reader who ticks an accept half-way through has changed what their pass
1596
+ // contains, and a command line cannot carry the new contents.
1597
+ drawCmdList()
1323
1598
  var n = pending()
1324
1599
  var open = openNotes()
1325
1600
  verdictCount.textContent = n
@@ -1391,6 +1666,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1391
1666
  'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
1392
1667
  'Nothing here reaches Claude any more, so the verdict buttons are closed.'
1393
1668
  wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
1669
+ // The commands go with the bar. A decided page offers no way to decide
1670
+ // again, whichever shape it was offering.
1671
+ if (cmdList) cmdList.hidden = true
1394
1672
  // EVERY button, not only the committing pair: `refresh` re-enables those two
1395
1673
  // by design, and a decided page must beat it.
1396
1674
  Object.keys(verdictBtns).forEach(function (key) {
@@ -1404,29 +1682,54 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1404
1682
  // sentence again in smaller type, so it goes.
1405
1683
  verdictLog.hidden = true
1406
1684
 
1685
+ // WHAT THE PAGE ESTABLISHED, replayed — never re-derived. `picked` is the
1686
+ // answer the poll got, and a re-opened tab has no way to ask again for a
1687
+ // pass that is long gone, so it says what it said before rather than
1688
+ // quietly changing its mind a day later.
1689
+ copyHint.hidden = true
1690
+ copyOut.hidden = true
1691
+ if (decided.picked === true) {
1692
+ // NOTHING TO RUN, and so nothing offered. This is the whole point of
1693
+ // asking: the command used to appear here on every single ending, which
1694
+ // taught the reader it was decoration.
1695
+ sentCmd.hidden = true
1696
+ decidedNote.textContent += ' Claude picked this up — there is nothing to run.'
1697
+ return
1698
+ }
1407
1699
  // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
1408
1700
  // worth carrying. A code is six digits to transcribe, and the box and its
1409
1701
  // Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
1410
1702
  // reader is about to type into the terminal they are already sitting in;
1411
1703
  // dressing it as a copyable artefact makes the ending look unfinished.
1412
1704
  if (decided.cmd && /\s/.test(decided.cmd)) {
1413
- copyHint.hidden = false
1414
- copyHint.textContent = 'Sent. Run this where Claude is:'
1705
+ decidedNote.textContent += ' It is still waiting to be picked up.'
1415
1706
  showCommand(decided.cmd)
1416
1707
  return
1417
1708
  }
1418
- copyHint.hidden = true
1419
1709
  sentCmd.hidden = true
1420
- copyOut.hidden = true
1421
1710
  if (decided.cmd) {
1422
1711
  decidedNote.textContent +=
1423
1712
  ' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
1424
1713
  }
1425
1714
  }
1426
1715
 
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 }
1716
+ /**
1717
+ * Record a verdict that was actually delivered, and end the page on it.
1718
+ *
1719
+ * `picked` is three-valued and the third value is the useful one: `true` the
1720
+ * engine confirmed somebody claimed this pass, `false` it confirmed nobody
1721
+ * has, and `null` NOTHING COULD BE ESTABLISHED — an older daemon with no
1722
+ * lookup, a poll that never came back, a page that stopped asking. Only
1723
+ * `true` takes the command away, so every way of not knowing leaves it on
1724
+ * screen (`.claude/rules/negative-checks.md` rule 4).
1725
+ */
1726
+ function markDecided(verdict, cmd, picked) {
1727
+ decided = {
1728
+ verdict: verdict,
1729
+ at: new Date().toISOString(),
1730
+ cmd: cmd || null,
1731
+ picked: picked === undefined ? null : picked,
1732
+ }
1430
1733
  save()
1431
1734
  drawDecided()
1432
1735
  }
@@ -1447,6 +1750,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1447
1750
  approve: 'committed',
1448
1751
  commit: 'committed',
1449
1752
  'commit-continue': 'committed, then carried on',
1753
+ 'commit-start': 'committed, then put in flight',
1450
1754
  continue: 'read it and carried on',
1451
1755
  changes: 'changes requested',
1452
1756
  discuss: 'discussed',
@@ -1482,29 +1786,122 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1482
1786
  * here, not an edge one. A Copy button that does nothing when tapped is worse
1483
1787
  * than text that was always text, so the button only exists where it can work.
1484
1788
  */
1789
+ /**
1790
+ * Put the whole command in the reader's selection. Returns whether it worked.
1791
+ *
1792
+ * REPORTED, NOT ASSUMED. It replaces `input.select()`, which the box no longer
1793
+ * has anything to call — and the honest thing to do when a browser withholds
1794
+ * `getSelection` is to stop claiming the text is selected, rather than say so
1795
+ * and leave someone hunting for a highlight that is not there.
1796
+ */
1797
+ function selectCommand() {
1798
+ return selectNode(sentCmdText)
1799
+ }
1800
+
1801
+ /**
1802
+ * Put a node's whole text in the reader's selection. Returns whether it took.
1803
+ */
1804
+ function selectNode(node) {
1805
+ try {
1806
+ var range = document.createRange()
1807
+ range.selectNodeContents(node)
1808
+ var sel = window.getSelection && window.getSelection()
1809
+ if (!sel) return false
1810
+ sel.removeAllRanges()
1811
+ sel.addRange(range)
1812
+ return true
1813
+ } catch (e) {
1814
+ return false
1815
+ }
1816
+ }
1817
+
1818
+ /**
1819
+ * Can this browser copy AT ALL — by either route?
1820
+ *
1821
+ * WHAT THIS GOT WRONG, and why the button kept vanishing where it was needed
1822
+ * most: `navigator.clipboard` is **secure-context only**, so it is absent on
1823
+ * every `http://<lan-ip>:7777` page — which is exactly the page someone reads
1824
+ * on a phone. Asking only about that API therefore hid the Copy button in the
1825
+ * one case the button exists for, and left a phone reader long-pressing to
1826
+ * select text on a page that could have copied for them all along.
1827
+ *
1828
+ * `document.execCommand('copy')` is deprecated and universally implemented,
1829
+ * and it works in a non-secure context inside a user gesture. So there are
1830
+ * two routes, and the rule below is unchanged: offer the button only where
1831
+ * one of them exists.
1832
+ */
1833
+ function canCopy() {
1834
+ return Boolean(
1835
+ (navigator.clipboard && navigator.clipboard.writeText) ||
1836
+ (document.execCommand && window.getSelection),
1837
+ )
1838
+ }
1839
+
1840
+ /**
1841
+ * Copy `text`, by whichever route this browser has. Resolves on success and
1842
+ * rejects otherwise, so every caller reports the same three outcomes.
1843
+ *
1844
+ * `node` is what gets left selected when copying fails — the reader can then
1845
+ * do it by hand, which is the behaviour this button replaced rather than
1846
+ * removed.
1847
+ */
1848
+ function copyText(text, node) {
1849
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1850
+ return navigator.clipboard.writeText(text)
1851
+ }
1852
+ return new Promise(function (resolve, reject) {
1853
+ // The selection IS the mechanism here, not a consolation: execCommand
1854
+ // copies what is selected. So it is made first and deliberately left in
1855
+ // place — a failed copy then lands the reader exactly where the old
1856
+ // no-clipboard path did.
1857
+ if (!selectNode(node)) return reject(new Error('no selection'))
1858
+ var ok = false
1859
+ try {
1860
+ ok = document.execCommand('copy')
1861
+ } catch (e) {
1862
+ ok = false
1863
+ }
1864
+ return ok ? resolve() : reject(new Error('execCommand refused'))
1865
+ })
1866
+ }
1867
+
1485
1868
  function showCommand(cmd) {
1486
1869
  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)'
1870
+ sentCmdText.textContent = cmd
1871
+ var copyable = canCopy()
1872
+ sentCmdCopy.hidden = !copyable
1873
+ sentCmdCopy.textContent = 'Copy'
1874
+ // SET HERE, NOT LEFT TO THE MARKUP. The lead says two different things and
1875
+ // the box is shown more than once in a page's life, so a line that was only
1876
+ // ever correct on first render would go stale the moment either changed.
1877
+ if (copyable) {
1878
+ sentCmdLead.textContent = 'Run this where Claude is:'
1879
+ // NOT SELECTED when there is a button. Pre-selecting made the line look
1880
+ // like a focused input, which is what it was mistaken for — and it is the
1881
+ // long-press-to-select gesture the button is here to spare a phone reader.
1882
+ return
1883
+ }
1884
+ // Selected where that is possible, and SAID to be selected: a reader who
1885
+ // sees nothing happen has no way to tell a page that did nothing from one
1886
+ // that already did the work. The lead carries it now that the box lives in
1887
+ // the decided panel — `copy-hint` is down in the footer with the verdict
1888
+ // bar, and appending to it put the sentence half a page from the thing it
1889
+ // describes.
1890
+ sentCmdLead.textContent = selectCommand()
1891
+ ? 'Run this where Claude is (selected — copy it):'
1892
+ : 'Run this where Claude is — copy it:'
1496
1893
  }
1497
1894
 
1498
1895
  if (sentCmdCopy) {
1499
1896
  sentCmdCopy.addEventListener('click', function () {
1500
- navigator.clipboard.writeText(sentCmdText.value).then(function () {
1897
+ copyText(sentCmdText.textContent, sentCmdText).then(function () {
1501
1898
  sentCmdCopy.textContent = 'Copied'
1502
1899
  }, function () {
1503
1900
  // 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'
1901
+ // same selection the no-clipboard path uses rather than saying nothing
1902
+ // and if even that is unavailable, say THAT, because a button reading
1903
+ // "selected" over nothing highlighted is worse than an honest failure.
1904
+ sentCmdCopy.textContent = selectCommand() ? 'Copy failed — selected' : 'Copy failed'
1508
1905
  })
1509
1906
  })
1510
1907
  }
@@ -1533,16 +1930,64 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1533
1930
  into.appendChild(h)
1534
1931
  }
1535
1932
 
1933
+ /**
1934
+ * THE PHASE COMES FIRST, and the spec's Problem is folded away behind it.
1935
+ *
1936
+ * The open part of this header used to be `Why` — the overview's Problem,
1937
+ * which is about the whole spec. A reader opening a page for phase 3 got
1938
+ * the same paragraph they had already read at phase 1, and had to expand
1939
+ * `More` to reach the only thing that told them what THIS review covers.
1940
+ * The question a review page answers is "what am I looking at", so the
1941
+ * phase answers it, and the background it sits in is one tap away.
1942
+ *
1943
+ * The TASK LIST is open too, deliberately. It is the cheapest statement of
1944
+ * what is and is not in this diff — which matters most half-way through a
1945
+ * phase, where a `/spec-diff` shows three ticked boxes and four empty ones
1946
+ * and the empty ones are the reason the diff looks unfinished.
1947
+ */
1948
+ if (ctx.phase) {
1949
+ head(why, 'Phase ' + ctx.phase.n + (ctx.phase.title ? ' — ' + ctx.phase.title : ''))
1950
+ if (ctx.phase.goal) {
1951
+ var g = document.createElement('p')
1952
+ g.textContent = ctx.phase.goal
1953
+ why.appendChild(g)
1954
+ }
1955
+ if (ctx.phase.tasks && ctx.phase.tasks.length) {
1956
+ var ul = document.createElement('ul')
1957
+ ul.className = 'tasks'
1958
+ ctx.phase.tasks.forEach(function (task) {
1959
+ var li = document.createElement('li')
1960
+ // Done is dimmed rather than struck through: every task is ticked by
1961
+ // the time a phase renders, and a whole list struck through reads as
1962
+ // cancelled work.
1963
+ li.className = task.done ? 'done' : ''
1964
+ li.textContent = (task.done ? '✅ ' : '⬜ ') + task.text
1965
+ ul.appendChild(li)
1966
+ })
1967
+ why.appendChild(ul)
1968
+ }
1969
+ }
1970
+
1536
1971
  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.
1972
+ // FOLDED, ALL OF IT. With the phase open above, the spec's Problem is
1973
+ // background rather than the answer and on a phone every line of it is
1974
+ // a line of the file list pushed off-screen, which is the thing this
1975
+ // header was built to avoid. Where there is NO phase (a branch-wide diff,
1976
+ // a spec with no live phase) the first paragraph stays open, because then
1977
+ // it is the only thing the header can say.
1540
1978
  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++) {
1979
+ var from = 0
1980
+ if (!ctx.phase) {
1981
+ head(why, 'Why')
1982
+ var lead = document.createElement('p')
1983
+ lead.textContent = paras[0].replace(/\s+/g, ' ').trim()
1984
+ why.appendChild(lead)
1985
+ from = 1
1986
+ } else if (paras.length) {
1987
+ head(rest, 'Why')
1988
+ drewRest = true
1989
+ }
1990
+ for (var i = from; i < paras.length; i++) {
1546
1991
  var p = document.createElement('p')
1547
1992
  p.textContent = paras[i].replace(/\s+/g, ' ').trim()
1548
1993
  rest.appendChild(p)
@@ -1579,29 +2024,6 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1579
2024
  }
1580
2025
  }
1581
2026
 
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
2027
  document.getElementById('context').hidden = false
1606
2028
  more.hidden = !drewRest
1607
2029
  }
@@ -1615,6 +2037,91 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1615
2037
  copyHint.textContent = why
1616
2038
  }
1617
2039
 
2040
+ /**
2041
+ * Put the whole recovery command in the failure callout, with its Copy control.
2042
+ *
2043
+ * THE SAME CONTROL THE `file://` PAGE ALREADY USES — `/spec-reviewed <verdict>`
2044
+ * — so there is one command row rather than a second kind. It reaches the
2045
+ * agent regardless of any server, which is the only reason a recovery can be
2046
+ * offered from a failed send at all.
2047
+ *
2048
+ * `canCopy` decides whether there is a button, exactly as `showCommand` does:
2049
+ * a control that does nothing on tap reads as a broken page, and one that is
2050
+ * missing where it would have worked reads as a page that never offered it.
2051
+ */
2052
+ function showFailCommand(cmd) {
2053
+ failCmd.hidden = false
2054
+ failText.textContent = cmd
2055
+ var copyable = canCopy()
2056
+ failCopy.hidden = !copyable
2057
+ failCopy.textContent = 'Copy'
2058
+ failLead.textContent = copyable
2059
+ ? 'Your verdict is not lost — run this where Claude is:'
2060
+ : selectNode(failText)
2061
+ ? 'Your verdict is not lost — run this where Claude is (selected — copy it):'
2062
+ : 'Your verdict is not lost — run this where Claude is — copy it:'
2063
+ }
2064
+
2065
+ if (failCopy) {
2066
+ failCopy.addEventListener('click', function () {
2067
+ copyText(failText.textContent, failText).then(function () {
2068
+ failCopy.textContent = 'Copied'
2069
+ }, function () {
2070
+ failCopy.textContent = selectNode(failText) ? 'Copy failed — selected' : 'Copy failed'
2071
+ })
2072
+ })
2073
+ }
2074
+
2075
+ /** Take the failure panel down — a fresh attempt, or one that landed. */
2076
+ function clearSendFailure() {
2077
+ failBox.hidden = true
2078
+ failCmd.hidden = true
2079
+ failWhat.textContent = ''
2080
+ failNote.textContent = ''
2081
+ }
2082
+
2083
+ /**
2084
+ * Say that a send did not land, as loudly as a send that did.
2085
+ *
2086
+ * THE ONE OUTCOME THE READER MUST ACT ON used to be reported more quietly than
2087
+ * the one they need do nothing about: a small grey line in the footer, under a
2088
+ * verdict bar that had just closed. It gets the panel at the top of the column
2089
+ * now, in the delete palette.
2090
+ *
2091
+ * IT DOES NOT DECIDE THE PAGE. Nothing was delivered, so the diff stays and
2092
+ * the verdict buttons stay live — a page that closed its controls over a
2093
+ * failed send would strand the reader with no way to try again.
2094
+ *
2095
+ * A pass carrying MARKS still goes to the clipboard, because a command line
2096
+ * cannot carry notes and a failed send must not become the moment they are
2097
+ * silently dropped.
2098
+ */
2099
+ function sendFailed(json, verdict, what, note) {
2100
+ copyHint.hidden = true
2101
+ copyHint.textContent = ''
2102
+ failBox.hidden = false
2103
+ failWhat.textContent = what
2104
+ if (pending() > 0) {
2105
+ failCmd.hidden = true
2106
+ failNote.textContent =
2107
+ note +
2108
+ ' You have marked things up, and a command line cannot carry notes — the whole pass is ' +
2109
+ 'below. Copy it and paste it to Claude.'
2110
+ copyOut.hidden = false
2111
+ copyOut.value = json
2112
+ copyOut.focus()
2113
+ copyOut.select()
2114
+ } else {
2115
+ failNote.textContent = note
2116
+ showFailCommand('/spec-reviewed ' + verdict)
2117
+ }
2118
+ // WHERE THE READER IS LOOKING. The verdict bar is at the foot of the page
2119
+ // and this panel is at the head of it, so a failure announced without this
2120
+ // is a failure announced off-screen — which is the shape of the bug being
2121
+ // fixed, one scroll position removed.
2122
+ if (failBox.scrollIntoView) failBox.scrollIntoView()
2123
+ }
2124
+
1618
2125
  // One action per decision: the verdict button IS the copy button, so the blob
1619
2126
  // can never carry a verdict other than the one just pressed.
1620
2127
  /**
@@ -1625,6 +2132,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1625
2132
  * much you wrote.
1626
2133
  */
1627
2134
  function post(json, verdict) {
2135
+ clearSendFailure()
1628
2136
  copyHint.hidden = false
1629
2137
  copyHint.textContent = 'Sending…'
1630
2138
  fetch(location.pathname, {
@@ -1634,11 +2142,45 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1634
2142
  }).then(function (res) {
1635
2143
  return res.text().then(function (text) {
1636
2144
  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)
2145
+ // A pass the server REFUSED must never read as sent and WHICH
2146
+ // refusal decides where the reader goes next, so the two are named
2147
+ // separately rather than collapsed into one "not sent".
2148
+ if (res.status === 404) {
2149
+ // THE PORT MOVED, or another repo's daemon holds this one. The token
2150
+ // in this URL means nothing there, so re-opening the page from the
2151
+ // session that produced it is the fix — the link is stale, the pass
2152
+ // is fine.
2153
+ sendFailed(
2154
+ json,
2155
+ verdict,
2156
+ 'Not sent — this page\'s server is gone.',
2157
+ 'Nothing is answering at this address any more: the review server moved to another ' +
2158
+ 'port, or a different repo holds this one. Re-open the page from the Claude Code ' +
2159
+ 'session that produced it and the buttons below will send.',
2160
+ )
2161
+ } else if (res.status === 422) {
2162
+ // THE ENGINE READ IT AND SAID NO. Its message names the entry that
2163
+ // was wrong, so it is relayed verbatim — paraphrasing is how a
2164
+ // reader ends up hunting for a problem the tool had already named.
2165
+ // AND NO "re-open": the page is fine, and re-opening would only be
2166
+ // refused again.
2167
+ sendFailed(
2168
+ json,
2169
+ verdict,
2170
+ 'Not sent — the engine refused this pass.',
2171
+ text || 'It answered ' + res.status + ' without saying why.',
2172
+ )
2173
+ } else {
2174
+ sendFailed(
2175
+ json,
2176
+ verdict,
2177
+ 'Not sent — the server answered ' + res.status + '.',
2178
+ text || 'It gave no reason.',
2179
+ )
2180
+ }
1640
2181
  return
1641
2182
  }
2183
+ clearSendFailure()
1642
2184
  var code = ''
1643
2185
  try { code = JSON.parse(text).code } catch (e) {}
1644
2186
  // NAME THE COMMAND. Not "claim it with 418207", which instructs a
@@ -1648,25 +2190,194 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1648
2190
  // stays, as the thing to CHECK: it is the one part of a waiting pass
1649
2191
  // that tells yours apart from anyone else's.
1650
2192
  if (code) {
1651
- copyHint.textContent = 'Sent. Run this where Claude is:'
1652
- showCommand('/spec-reviewed ' + code)
1653
- markDecided(verdict, '/spec-reviewed ' + code)
2193
+ // SENT, AND NOW ASK WHAT BECAME OF IT. The page used to name the
2194
+ // command here unconditionally — the same sentence whether a session
2195
+ // was waiting to claim the pass or nothing was — because it had no
2196
+ // way to find out. It has one now.
2197
+ markDecided(verdict, '/spec-reviewed ' + code, null)
2198
+ watchPass(code, verdict)
1654
2199
  } else {
1655
2200
  // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
1656
2201
  // works — it picks up the single waiting pass — but the page cannot
1657
2202
  // claim the server named THIS one, so it does not put words in its
1658
2203
  // mouth. Bare is what the reader is told, because bare is what is true.
1659
2204
  copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
1660
- markDecided(verdict)
2205
+ markDecided(verdict, null, null)
1661
2206
  }
1662
2207
  })
1663
2208
  }, 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.')
2209
+ // THE REQUEST NEVER ARRIVED the network, or a server that has stopped.
2210
+ // Distinct from both refusals above: there is no status and no message,
2211
+ // because nothing answered. Re-opening may fix it and the command
2212
+ // certainly will, so both are offered.
2213
+ sendFailed(
2214
+ json,
2215
+ verdict,
2216
+ 'Not sent — could not reach the server.',
2217
+ 'The request did not arrive: the server has stopped, or this device cannot reach it. ' +
2218
+ 'Re-open the page from the session that produced it to try again.',
2219
+ )
1667
2220
  })
1668
2221
  }
1669
2222
 
2223
+ /* ==========================================================================
2224
+ * The command list — a verdict bar for a page that cannot deliver one
2225
+ *
2226
+ * A `file://` page has no server and no store, so its buttons never sent
2227
+ * anything: they built a JSON blob and put it on the clipboard to paste. That
2228
+ * is still the only way a pass carrying MARKS can travel, and it stays. What
2229
+ * changes is the common case — a reader who ticked nothing and wrote nothing,
2230
+ * whose whole pass is one word — where a command they run in the terminal
2231
+ * beside them beats a wall of text every time.
2232
+ *
2233
+ * WHICH SHAPE IS LIVE IS DECIDED BY WHAT THE PASS CARRIES, and it is
2234
+ * re-decided on every mark. Tick one accept and the rows stand down, because
2235
+ * a command line cannot carry someone's notes and dropping them silently
2236
+ * would be worse than asking for the paste. Clear it again and they return.
2237
+ * ======================================================================== */
2238
+
2239
+ // Only a `file://` page. A served page posts; a published one writes to its
2240
+ // store. Both can deliver, so both keep their buttons — decided from what the
2241
+ // page IS, exactly as `send` decides.
2242
+ var NO_TRANSPORT = location.protocol === 'file:' && !isArtifact()
2243
+ var cmdList = document.getElementById('cmd-list')
2244
+ var cmdLead = document.getElementById('cmd-lead')
2245
+ var cmdRows = {}
2246
+
2247
+ function buildCmdRows() {
2248
+ if (!NO_TRANSPORT || !cmdList) return
2249
+ // WHERE, not just what. "Run one of these where it is" assumed the reader
2250
+ // knew which terminal counts, and there is usually more than one open. The
2251
+ // answer is specific and worth saying: the session that produced this page
2252
+ // is the one holding the review.
2253
+ cmdLead.textContent =
2254
+ 'This page has no way to reach Claude. Copy one of these and paste it into the ' +
2255
+ 'Claude Code session that produced this review — that is the one waiting for your verdict.'
2256
+ // THE SAME LIST THE BAR USES. `OFFERED` already answers which verdicts this
2257
+ // render offers — a mid-run page lists Continue and never the committing
2258
+ // pair — so deriving the rows from it is what keeps the two from drifting.
2259
+ OFFERED.forEach(function (verdict) {
2260
+ var row = el('div', 'cmd-row')
2261
+ row.setAttribute('data-verdict', verdict)
2262
+ var label = el('span', 'cmd-label', LABELS[verdict])
2263
+ var cmd = el('code', 'cmd-text', '/spec-reviewed ' + verdict)
2264
+ row.appendChild(label)
2265
+ row.appendChild(cmd)
2266
+ // NEVER A BUTTON THAT CANNOT COPY — the same rule `showCommand` follows,
2267
+ // through the same `canCopy`. A control that does nothing on tap reads as
2268
+ // a broken page; a control that is MISSING where it would have worked
2269
+ // reads as a page that never offered it, which is the failure this shares
2270
+ // its capability check to avoid.
2271
+ if (canCopy()) {
2272
+ var copy = el('button', 'cmd-copy', 'Copy')
2273
+ copy.setAttribute('type', 'button')
2274
+ copy.addEventListener('click', function () {
2275
+ copyText(cmd.textContent, cmd).then(function () {
2276
+ // COPIED IS DECIDED. The reader has the command; what happens next
2277
+ // happens in their terminal, and the page has nothing left to do
2278
+ // but say what they chose — with the command repeated, because a
2279
+ // copy can fail silently and an hour later this box is the only
2280
+ // record of it.
2281
+ markDecided(verdict, cmd.textContent, false)
2282
+ }, function () {
2283
+ copy.textContent = selectNode(cmd) ? 'Copy failed — selected' : 'Copy failed'
2284
+ })
2285
+ })
2286
+ row.appendChild(copy)
2287
+ }
2288
+ cmdList.appendChild(row)
2289
+ cmdRows[verdict] = row
2290
+ })
2291
+ }
2292
+
2293
+ /**
2294
+ * Decide, now, which of the two shapes this page is offering.
2295
+ *
2296
+ * Called from `refresh`, so it re-answers on every mark rather than once at
2297
+ * load: the reader who ticks an accept half-way through has changed what
2298
+ * their pass contains, and the page has to change with them.
2299
+ */
2300
+ function drawCmdList() {
2301
+ if (!NO_TRANSPORT || !cmdList) return
2302
+ var marked = pending() > 0
2303
+ cmdList.hidden = marked
2304
+ Object.keys(cmdRows).forEach(function (k) { cmdRows[k].hidden = marked })
2305
+ // The bar comes back to carry what a command cannot, and SAYS SO. A reader
2306
+ // whose notes had been dropped would not find out until the review came
2307
+ // back without them.
2308
+ verdictEl.hidden = !marked
2309
+ if (marked) {
2310
+ copyHint.hidden = false
2311
+ copyHint.textContent =
2312
+ 'You have marked something up, and a command line cannot carry notes — ' +
2313
+ 'use a verdict button below and paste what it copies.'
2314
+ } else if (copyHint.textContent.indexOf('cannot carry notes') !== -1) {
2315
+ copyHint.hidden = true
2316
+ copyHint.textContent = ''
2317
+ }
2318
+ }
2319
+
2320
+ // How long to keep asking, and how often. A claim is a file-watch waking a
2321
+ // session that is already sitting there, so the answer normally arrives in
2322
+ // well under a second; these are sized for the case where it does not.
2323
+ var PASS_POLL_MS = 900
2324
+ var PASS_POLL_ROUNDS = 8
2325
+
2326
+ /**
2327
+ * Ask the engine what became of the pass we just sent, and end on the answer.
2328
+ *
2329
+ * THE PAGE STOPPED GUESSING. It used to hand over `/spec-reviewed <code>` on
2330
+ * every successful POST, because it could not tell an auto-claimed pass from
2331
+ * one nobody was listening for — so the reader was told to run a command they
2332
+ * almost never needed, in the same words used when running it was the only
2333
+ * thing that would save their review. Both sentences are now earned.
2334
+ *
2335
+ * ONE ANSWER TAKES THE COMMAND AWAY, and it is the positive one: `claimed`,
2336
+ * which the engine reads off a decision log that NAMES this code. Everything
2337
+ * else — `waiting`, `unknown`, a server that never replied, a body that did
2338
+ * not parse, an older daemon with no lookup, the rounds running out — leaves
2339
+ * the command exactly where it is. A command nobody needed to run costs a
2340
+ * glance; a pass nobody claims costs the review.
2341
+ *
2342
+ * WHAT WOULD FOOL A LOOSER VERSION: treating a failed poll as "probably fine".
2343
+ * The failures that make a poll fail are the same ones that make a claim fail
2344
+ * — the machine sleeping, the daemon restarting, the network going — so the
2345
+ * moment this is least able to see is the moment its answer matters most.
2346
+ */
2347
+ function watchPass(code, verdict) {
2348
+ var rounds = 0
2349
+ // `?pass=` on the page's own path, so the serve token in the URL guards
2350
+ // this exactly as it guards everything else and there is no second address.
2351
+ var url = location.pathname + '?pass=' + encodeURIComponent(code)
2352
+
2353
+ function stop(picked) {
2354
+ markDecided(verdict, '/spec-reviewed ' + code, picked)
2355
+ }
2356
+
2357
+ function ask() {
2358
+ rounds++
2359
+ fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
2360
+ if (!res || !res.ok) return again()
2361
+ return res.text().then(function (text) {
2362
+ var state = null
2363
+ try { state = JSON.parse(text).state } catch (e) {}
2364
+ if (state === 'claimed') return stop(true)
2365
+ return again()
2366
+ })
2367
+ }, again)
2368
+ }
2369
+
2370
+ function again() {
2371
+ // Out of rounds is a CANNOT-TELL, not a no. Nobody is obliged to claim a
2372
+ // pass inside seven seconds, and a page that concluded "abandoned" would
2373
+ // be inventing an answer out of its own impatience.
2374
+ if (rounds >= PASS_POLL_ROUNDS) return stop(false)
2375
+ window.setTimeout(ask, PASS_POLL_MS)
2376
+ }
2377
+
2378
+ ask()
2379
+ }
2380
+
1670
2381
  /**
1671
2382
  * Is this page running as a published Artifact?
1672
2383
  *
@@ -1714,7 +2425,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1714
2425
  // the one thing that is true and actionable.
1715
2426
  copyHint.textContent = 'Sent. Run this where Claude is:'
1716
2427
  showCommand('/spec-reviewed')
1717
- markDecided(verdict, '/spec-reviewed')
2428
+ // A PUBLISHED PAGE CANNOT ASK. The pass went into the artifact's own
2429
+ // store, which the engine only reads when someone tells it to, so
2430
+ // nothing here could ever come back `true` — and the command is the
2431
+ // whole ending rather than a fallback within it.
2432
+ markDecided(verdict, '/spec-reviewed', null)
1718
2433
  })
1719
2434
  }).then(null, function (err) {
1720
2435
  if (landed) return
@@ -1762,6 +2477,181 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1762
2477
  })
1763
2478
  }
1764
2479
 
2480
+ /**
2481
+ * The surfaces strip: where this review lives, and whether it is also running.
2482
+ *
2483
+ * AN ACTION IS NOT A VERDICT, and the whole strip exists to make that visible.
2484
+ * A verdict is the reader's conclusion, consumed once. These change what is
2485
+ * RUNNING (or which tiers are permitted) and hand the reader straight back to
2486
+ * the same page with the same options — so pressing one must not close the
2487
+ * page into its decided state, and must not clear a gate.
2488
+ *
2489
+ * ENABLE-ONLY for the tiers. Turning `network` off from a page reached over
2490
+ * the network kills the page doing the turning, and the reader gains nothing
2491
+ * they could not get by typing the command — so the engine's `tierAction`
2492
+ * never offers a disable and this renders whatever it is given.
2493
+ */
2494
+ var ACTION_LABEL = {
2495
+ 'live-on': '▶ Put it live',
2496
+ 'allow-network': '▶ Allow network',
2497
+ 'allow-remote': '▶ Allow remote',
2498
+ }
2499
+ var ACTION_CMD = {
2500
+ 'live-on': '/spec-live',
2501
+ 'allow-network': 'skitterspec spec-env review allow network',
2502
+ 'allow-remote': '/spec-remote-review',
2503
+ }
2504
+ var SURFACE_LABEL = { live: 'live', network: 'network', remote: 'remote' }
2505
+
2506
+ function surfaceStateText(row) {
2507
+ if (row.kind === 'live') {
2508
+ if (row.state === 'on') return row.url ? 'on — running at ' + row.url : 'on'
2509
+ if (row.state === 'held') return row.reason || 'held by another spec'
2510
+ return 'off'
2511
+ }
2512
+ if (row.state === 'on') return row.url ? 'on — ' + row.url : 'on'
2513
+ return row.state === 'off' ? 'off' : 'unavailable'
2514
+ }
2515
+
2516
+ function buildSurfaces() {
2517
+ var section = document.getElementById('surfaces')
2518
+ var host = document.getElementById('surface-rows')
2519
+ // ABSENT, NOT EMPTY. A render that carried no surfaces gets no strip and no
2520
+ // line explaining the absence — a `--docs` page has no branch to put live,
2521
+ // and that is a healthy page rather than a deprived one.
2522
+ if (!section || !host || !data.surfaces || !data.surfaces.length) return
2523
+ section.hidden = false
2524
+ data.surfaces.forEach(function (row) {
2525
+ var div = document.createElement('div')
2526
+ div.className = 'surface-row'
2527
+ var label = document.createElement('span')
2528
+ label.className = 'surface-label'
2529
+ label.textContent = SURFACE_LABEL[row.kind === 'live' ? 'live' : row.tier] || ''
2530
+ div.appendChild(label)
2531
+ var state = document.createElement('span')
2532
+ state.className = 'surface-state'
2533
+ state.textContent = surfaceStateText(row)
2534
+ div.appendChild(state)
2535
+ if (row.action) {
2536
+ if (NO_TRANSPORT) {
2537
+ // No server and no store, so a button here would build an instruction
2538
+ // with nowhere to go. The command is the honest control — exactly the
2539
+ // trade the verdict bar already makes on a `file://` page.
2540
+ var cmd = document.createElement('code')
2541
+ cmd.className = 'surface-cmd'
2542
+ cmd.textContent = ACTION_CMD[row.action] || ''
2543
+ div.appendChild(cmd)
2544
+ } else {
2545
+ var btn = document.createElement('button')
2546
+ btn.type = 'button'
2547
+ btn.textContent = ACTION_LABEL[row.action] || row.action
2548
+ btn.setAttribute('data-action', row.action)
2549
+ btn.addEventListener('click', function () {
2550
+ if (btn.disabled) return
2551
+ sendAction(row.action, btn)
2552
+ })
2553
+ div.appendChild(btn)
2554
+ }
2555
+ }
2556
+ // THE COMMAND FOR A DIRECTION THE PAGE DOES NOT OFFER. A live spec shows
2557
+ // no press — handing the instance back to `main` is a workspace decision,
2558
+ // not this review's — so the line names `/spec-live main` instead, and the
2559
+ // reader is never left knowing what they want and not what to type.
2560
+ if (row.command && !row.action) {
2561
+ var cmdOnly = document.createElement('code')
2562
+ cmdOnly.className = 'surface-cmd'
2563
+ cmdOnly.textContent = row.command
2564
+ div.appendChild(cmdOnly)
2565
+ }
2566
+ if (row.note) {
2567
+ var note = document.createElement('span')
2568
+ note.className = 'surface-note'
2569
+ note.textContent = row.note
2570
+ div.appendChild(note)
2571
+ }
2572
+ host.appendChild(div)
2573
+ })
2574
+ }
2575
+
2576
+ /**
2577
+ * Send an action, and leave the page exactly as live as it was.
2578
+ *
2579
+ * IT CARRIES NO MARKS. An action blob is an instruction, not a review pass —
2580
+ * so the accepts and comments stay on the page and travel with the verdict
2581
+ * the reader eventually chooses. Sending them here would merge them early and
2582
+ * then send them again, which is one reader's review counted twice.
2583
+ *
2584
+ * AND IT NEVER CALLS `markDecided`. The reader still owes a verdict; closing
2585
+ * the page over an action would be the record-and-do-nothing ending this
2586
+ * whole contract is against.
2587
+ */
2588
+ function sendAction(action, btn) {
2589
+ var blob = {
2590
+ version: 1,
2591
+ spec: data.spec,
2592
+ generatedAt: data.generatedAt,
2593
+ accepted: [],
2594
+ unaccepted: [],
2595
+ comments: [],
2596
+ action: action,
2597
+ }
2598
+ btn.disabled = true
2599
+ var was = btn.textContent
2600
+ btn.textContent = 'Sending…'
2601
+ fetch(location.pathname, {
2602
+ method: 'POST',
2603
+ headers: { 'content-type': 'application/json' },
2604
+ body: JSON.stringify(blob),
2605
+ }).then(function (res) {
2606
+ if (!res || !res.ok) {
2607
+ btn.disabled = false
2608
+ btn.textContent = was
2609
+ return
2610
+ }
2611
+ return res.text().then(function (text) {
2612
+ var code = null
2613
+ try { code = JSON.parse(text).code } catch (e) {}
2614
+ btn.textContent = 'Sent'
2615
+ // RELOAD WHEN IT HAS BEEN ACTED ON, not on a timer. The agent re-renders
2616
+ // after the action, so the page the reader wants is the next one — and
2617
+ // `?pass=` already answers whether this exact code was claimed. Running
2618
+ // out of rounds leaves the page alone rather than reloading on a guess.
2619
+ if (code) watchAction(code, btn)
2620
+ })
2621
+ }, function () {
2622
+ btn.disabled = false
2623
+ btn.textContent = was
2624
+ })
2625
+ }
2626
+
2627
+ function watchAction(code, btn) {
2628
+ var rounds = 0
2629
+ var url = location.pathname + '?pass=' + encodeURIComponent(code)
2630
+ function ask() {
2631
+ rounds++
2632
+ fetch(url, { method: 'GET', headers: { accept: 'application/json' } }).then(function (res) {
2633
+ if (!res || !res.ok) return again()
2634
+ return res.text().then(function (text) {
2635
+ var state = null
2636
+ try { state = JSON.parse(text).state } catch (e) {}
2637
+ if (state === 'claimed') return window.location.reload()
2638
+ return again()
2639
+ })
2640
+ }, again)
2641
+ }
2642
+ function again() {
2643
+ // CANNOT TELL, so change nothing. Nobody is obliged to act on an action
2644
+ // inside a few seconds, and a page that reloaded out of impatience would
2645
+ // throw away marks the reader had not sent yet.
2646
+ if (rounds >= PASS_POLL_ROUNDS) {
2647
+ btn.textContent = 'Sent — reload when it is done'
2648
+ return
2649
+ }
2650
+ window.setTimeout(ask, PASS_POLL_MS)
2651
+ }
2652
+ ask()
2653
+ }
2654
+
1765
2655
  Object.keys(verdictBtns).forEach(function (verdict) {
1766
2656
  verdictBtns[verdict].addEventListener('click', function () {
1767
2657
  // A disabled or hidden button does not fire in a browser; the guard is
@@ -1788,6 +2678,8 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1788
2678
  }
1789
2679
 
1790
2680
  restore()
2681
+ buildCmdRows()
2682
+ buildSurfaces()
1791
2683
  drawContext()
1792
2684
  drawLog()
1793
2685
  drawDecided()
@@ -1813,7 +2705,54 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1813
2705
  })
1814
2706
 
1815
2707
  // --- header --------------------------------------------------------------
1816
- document.getElementById('title').textContent = data.title
2708
+ var titleEl = document.getElementById('title')
2709
+ titleEl.textContent = data.title
2710
+ /**
2711
+ * The tracker ticket, beside the title and linked where there is a url.
2712
+ *
2713
+ * `href` IS SET ONLY FROM A URL THE ENGINE ALREADY VETTED as http(s) — see
2714
+ * `readTicket`. The value originates in a spec file someone edits, and this
2715
+ * page is served over the network, so a `javascript:` url in an href would be
2716
+ * script execution. An id with no usable url still renders; it just does not
2717
+ * become a link, which is the harmless half of the pair.
2718
+ */
2719
+ ;(function () {
2720
+ var tk = data.context && data.context.ticket
2721
+ if (!tk || !tk.id) return
2722
+ var node
2723
+ if (tk.url) {
2724
+ node = document.createElement('a')
2725
+ node.setAttribute('href', tk.url)
2726
+ node.setAttribute('target', '_blank')
2727
+ node.setAttribute('rel', 'noopener noreferrer')
2728
+ } else {
2729
+ node = document.createElement('span')
2730
+ }
2731
+ node.className = 'ticket'
2732
+ node.textContent = tk.id
2733
+ titleEl.appendChild(document.createTextNode(' '))
2734
+ titleEl.appendChild(node)
2735
+ })()
2736
+
2737
+ /**
2738
+ * An ISO timestamp is a machine's format. The header showed
2739
+ * `2026-09-16T13:53:58.370Z` to a person deciding whether a page was stale,
2740
+ * which is the one question it is there to answer and the hardest way to ask
2741
+ * it. Rendered in the READER's locale and zone, because that is the clock
2742
+ * they are comparing against; the raw value stays as the `title` so nothing
2743
+ * precise is lost, and it survives a browser that throws on either call.
2744
+ */
2745
+ function whenRendered(iso) {
2746
+ try {
2747
+ var d = new Date(iso)
2748
+ if (isNaN(d.getTime())) return iso
2749
+ return d.toLocaleString(undefined, {
2750
+ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
2751
+ })
2752
+ } catch (e) {
2753
+ return iso
2754
+ }
2755
+ }
1817
2756
  var t = data.totals
1818
2757
  var sub = document.getElementById('sub')
1819
2758
  function part(text, cls) { sub.appendChild(el('span', cls, text)) }
@@ -1822,7 +2761,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1822
2761
  sep()
1823
2762
  part(data.mode === 'branch'
1824
2763
  ? 'everything since ' + data.base + (data.fellBack ? ' (working tree clean)' : '')
1825
- : 'uncommitted work')
2764
+ : data.mode === 'docs'
2765
+ ? "the spec's own documents"
2766
+ : 'uncommitted work')
1826
2767
  sep()
1827
2768
  part(t.files + ' file' + (t.files === 1 ? '' : 's'))
1828
2769
  sep()
@@ -1830,7 +2771,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1830
2771
  part(' ')
1831
2772
  part('-' + t.deletions, 'd')
1832
2773
  sep()
1833
- part(data.generatedAt)
2774
+ var when = el('span', null, whenRendered(data.generatedAt))
2775
+ when.setAttribute('title', data.generatedAt)
2776
+ sub.appendChild(when)
1834
2777
  })()
1835
2778
  </script>
1836
2779
  </body>