@skitterbyte/skitterspec 21.0.0 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +218 -0
- package/README.md +113 -6
- package/assets/claude-md-section.md +29 -18
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/core/env.config.json.example +4 -2
- package/assets/core/env.config.md +100 -23
- package/assets/review/page.html +1044 -101
- package/assets/rules/spec-planning.md +35 -3
- package/assets/rules/spec-reports.md +131 -20
- package/assets/skills/spec/SKILL.md +129 -1
- package/assets/skills/spec-bug/SKILL.md +29 -24
- package/assets/skills/spec-diff/SKILL.md +131 -36
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-next/SKILL.md +119 -56
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +19 -0
- package/package.json +1 -1
- package/src/cli.js +913 -116
- package/src/env/classify.js +87 -2
- package/src/env/config.js +214 -17
- package/src/env/live.js +94 -0
- package/src/env/resolve.js +36 -2
- package/src/env/review.js +542 -21
- package/src/env/serve.js +298 -19
- package/src/env/supervise.js +8 -1
- package/src/init.js +60 -9
package/assets/review/page.html
CHANGED
|
@@ -168,25 +168,58 @@ input:focus-visible,
|
|
|
168
168
|
font-size: .85rem;
|
|
169
169
|
font-weight: 600;
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
.
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
189
|
-
|
|
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
|
-
.
|
|
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>✓ Commit & 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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
b.
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1428
|
-
|
|
1429
|
-
|
|
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.
|
|
1488
|
-
var
|
|
1489
|
-
sentCmdCopy.hidden = !
|
|
1490
|
-
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
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
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
// the
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
|
1638
|
-
//
|
|
1639
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
:
|
|
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
|
-
|
|
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>
|