@agentsdance/codejury 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/bin/jury.js +1152 -0
- package/jury.config.example.json +43 -0
- package/lib/agents.js +295 -0
- package/lib/config.js +157 -0
- package/lib/findings.js +112 -0
- package/lib/loop.js +400 -0
- package/lib/prompt.js +67 -0
- package/lib/reply.js +134 -0
- package/lib/repository.js +185 -0
- package/lib/server.js +224 -0
- package/lib/store.js +316 -0
- package/lib/style.js +90 -0
- package/lib/triage.js +144 -0
- package/package.json +44 -0
- package/prompts/feedback.md +54 -0
- package/prompts/review-round.md +52 -0
- package/web/index.html +1699 -0
package/web/index.html
ADDED
|
@@ -0,0 +1,1699 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<title>Code Jury — Convergence Console</title>
|
|
3
|
+
<style>
|
|
4
|
+
:root {
|
|
5
|
+
--ground:#F4F6F8; --surface:#FFFFFF; --surface-alt:#EDF0F4;
|
|
6
|
+
--line:#D7DDE4; --line-soft:#E6EAEF; --ink:#14181D; --ink-2:#39434F; --muted:#5A6572;
|
|
7
|
+
--accent:#3A6B8A; --accent-soft:#E2ECF3; --main-hue:#6A4E8C; --main-soft:#EDE6F4;
|
|
8
|
+
--codex:#3A6B8A; --droid:#1F7A6B; --droid-soft:#DDF0EC; --agy:#4E7BD6;
|
|
9
|
+
--ok:#2E7D5B; --ok-soft:#E0F0E8; --warn:#B07A2B; --warn-soft:#F6ECD9;
|
|
10
|
+
--bad:#A8443A; --bad-soft:#F6E3E0;
|
|
11
|
+
--shadow:0 1px 2px rgba(20,24,29,.05), 0 8px 24px -14px rgba(20,24,29,.20);
|
|
12
|
+
--serif: ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
|
|
13
|
+
--sans: ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
14
|
+
--mono: ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root:not([data-theme="light"]) {
|
|
18
|
+
--ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B;
|
|
19
|
+
--line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2;
|
|
20
|
+
--accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31;
|
|
21
|
+
--codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5;
|
|
22
|
+
--ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013;
|
|
23
|
+
--bad:#E08076; --bad-soft:#2B1816;
|
|
24
|
+
--shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
:root[data-theme="dark"] {
|
|
28
|
+
--ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B;
|
|
29
|
+
--line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2;
|
|
30
|
+
--accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31;
|
|
31
|
+
--codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5;
|
|
32
|
+
--ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013;
|
|
33
|
+
--bad:#E08076; --bad-soft:#2B1816;
|
|
34
|
+
--shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
* { box-sizing:border-box; }
|
|
38
|
+
body { margin:0; background:var(--ground); color:var(--ink); font-family:var(--sans);
|
|
39
|
+
font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased; }
|
|
40
|
+
.wrap { max-width:1060px; margin:0 auto; padding:34px 22px 80px; display:flex; flex-direction:column; gap:38px; }
|
|
41
|
+
|
|
42
|
+
/* header */
|
|
43
|
+
.hd { display:flex; flex-direction:column; gap:14px; }
|
|
44
|
+
.kicker { font-family:var(--mono); font-size:10.5px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
|
|
45
|
+
h1 { font-family:var(--serif); font-size:clamp(26px,3.6vw,38px); font-weight:600; margin:0;
|
|
46
|
+
line-height:1.12; letter-spacing:-.015em; text-wrap:balance; }
|
|
47
|
+
.meta { display:flex; flex-wrap:wrap; gap:9px 20px; align-items:center;
|
|
48
|
+
font-family:var(--mono); font-size:12.5px; color:var(--muted); }
|
|
49
|
+
.meta a { color:var(--accent); text-decoration:none; border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent); }
|
|
50
|
+
.meta a:hover { border-bottom-color:var(--accent); }
|
|
51
|
+
.meta a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
52
|
+
.badge-ok { display:inline-flex; align-items:center; gap:7px; color:var(--ok); background:var(--ok-soft);
|
|
53
|
+
border:1px solid color-mix(in srgb,var(--ok) 34%,transparent); padding:4px 12px; border-radius:999px;
|
|
54
|
+
font-size:11px; letter-spacing:.07em; text-transform:uppercase; }
|
|
55
|
+
.badge-ok .d { width:7px; height:7px; border-radius:50%; background:currentColor; }
|
|
56
|
+
.badge-warn { display:inline-flex; align-items:center; gap:7px; color:var(--warn); background:var(--warn-soft);
|
|
57
|
+
border:1px solid color-mix(in srgb,var(--warn) 34%,transparent); padding:4px 12px; border-radius:999px;
|
|
58
|
+
font-size:11px; letter-spacing:.07em; text-transform:uppercase; }
|
|
59
|
+
.badge-mute { display:inline-flex; align-items:center; gap:7px; color:var(--muted); background:var(--surface-alt);
|
|
60
|
+
border:1px solid var(--line); padding:4px 12px; border-radius:999px;
|
|
61
|
+
font-size:11px; letter-spacing:.07em; text-transform:uppercase; }
|
|
62
|
+
.badge-warn .d,.badge-mute .d { width:7px; height:7px; border-radius:50%; background:currentColor; }
|
|
63
|
+
/* One control, not a chip per run. A row of chips is fine for five runs and
|
|
64
|
+
a wall for a hundred: it wrapped to two lines at seven, and the page header
|
|
65
|
+
stopped being a header. */
|
|
66
|
+
.switcher { position:relative; display:inline-block; }
|
|
67
|
+
.switcher:empty { display:none; }
|
|
68
|
+
.sw-btn {
|
|
69
|
+
display:inline-flex; align-items:center; gap:8px; max-width:min(560px, 90vw);
|
|
70
|
+
font:500 12px/1 var(--mono); color:var(--ink); background:var(--surface);
|
|
71
|
+
border:1px solid var(--line); border-radius:8px; padding:8px 11px; cursor:pointer;
|
|
72
|
+
}
|
|
73
|
+
.sw-btn:hover { border-color:var(--accent); }
|
|
74
|
+
.sw-btn .nm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
75
|
+
.sw-btn .cnt { color:var(--muted); font-size:11px; padding-left:8px;
|
|
76
|
+
margin-left:2px; border-left:1px solid var(--line-soft); }
|
|
77
|
+
.sw-btn .car { color:var(--muted); font-size:9px; margin-left:auto; }
|
|
78
|
+
.sw-pop {
|
|
79
|
+
position:absolute; top:calc(100% + 6px); left:0; z-index:40;
|
|
80
|
+
width:min(460px, 92vw); background:var(--surface); border:1px solid var(--line);
|
|
81
|
+
border-radius:10px; box-shadow:var(--shadow); overflow:hidden;
|
|
82
|
+
}
|
|
83
|
+
.sw-pop[hidden] { display:none; }
|
|
84
|
+
.sw-pop input {
|
|
85
|
+
width:100%; box-sizing:border-box; border:0; border-bottom:1px solid var(--line-soft);
|
|
86
|
+
background:transparent; color:var(--ink); font:400 13px/1 var(--sans); padding:11px 13px;
|
|
87
|
+
}
|
|
88
|
+
.sw-pop input:focus { outline:none; background:var(--surface-alt); }
|
|
89
|
+
.sw-list { max-height:min(52vh, 420px); overflow-y:auto; padding:5px; }
|
|
90
|
+
.sw-h {
|
|
91
|
+
font:600 10px/1 var(--sans); letter-spacing:.08em; text-transform:uppercase;
|
|
92
|
+
color:var(--muted); padding:9px 9px 5px;
|
|
93
|
+
}
|
|
94
|
+
.sw-i {
|
|
95
|
+
display:flex; align-items:center; gap:9px; width:100%; text-align:left;
|
|
96
|
+
background:none; border:0; border-radius:7px; padding:7px 9px; cursor:pointer;
|
|
97
|
+
font:400 12px/1.35 var(--sans); color:var(--ink);
|
|
98
|
+
}
|
|
99
|
+
.sw-i:hover, .sw-i[data-on="1"] { background:var(--surface-alt); }
|
|
100
|
+
.sw-i[aria-pressed="true"] { background:var(--accent-soft); }
|
|
101
|
+
.sw-i .d { width:7px; height:7px; border-radius:50%; flex:none; }
|
|
102
|
+
.sw-i .id { font-family:var(--mono); font-size:11.5px; flex:none; }
|
|
103
|
+
.sw-i .at { font:400 10.5px/1 var(--mono); color:var(--accent); flex:none; }
|
|
104
|
+
.sw-i .ti { color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
105
|
+
.sw-i .rt { margin-left:auto; flex:none; font:400 10.5px/1 var(--mono); color:var(--muted); }
|
|
106
|
+
.sw-none { padding:18px 13px; text-align:center; color:var(--muted); font:400 12px/1 var(--sans); }
|
|
107
|
+
.pr-chip { font-family:var(--mono); font-size:11.5px; padding:5px 11px; border-radius:3px; cursor:pointer;
|
|
108
|
+
border:1px solid var(--line); background:var(--surface); color:var(--muted);
|
|
109
|
+
display:inline-flex; align-items:center; gap:7px; }
|
|
110
|
+
.pr-chip:hover { border-color:var(--accent); color:var(--accent); }
|
|
111
|
+
.pr-chip:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
112
|
+
.pr-chip[aria-pressed="true"] { border-color:var(--accent); color:var(--accent); background:var(--accent-soft); }
|
|
113
|
+
.pr-chip .d { width:7px; height:7px; border-radius:50%; }
|
|
114
|
+
/* Repo qualifier, shown only when more than one repo is on screen. Dimmed so
|
|
115
|
+
the PR number stays the thing you scan for. */
|
|
116
|
+
.pr-chip .repo { opacity:.65; margin-right:4px; }
|
|
117
|
+
|
|
118
|
+
h2 { font-family:var(--serif); font-size:22px; font-weight:600; margin:0; letter-spacing:-.01em; }
|
|
119
|
+
section { display:flex; flex-direction:column; gap:14px; }
|
|
120
|
+
.note { margin:0; color:var(--muted); font-size:13.5px; max-width:66ch; }
|
|
121
|
+
|
|
122
|
+
/* timeline */
|
|
123
|
+
.tl { background:var(--surface); border:1px solid var(--line); border-radius:4px; box-shadow:var(--shadow);
|
|
124
|
+
padding:18px 20px 14px; display:flex; flex-direction:column; gap:12px; overflow-x:auto; }
|
|
125
|
+
.tl-inner { min-width:660px; display:flex; flex-direction:column; gap:10px; }
|
|
126
|
+
.tl-rounds { position:relative; height:20px; }
|
|
127
|
+
/* An abandoned span is drawn hollow: it happened, but it produced nothing,
|
|
128
|
+
and filling it the same as a completed round overstates the run. */
|
|
129
|
+
.seg[data-abandoned="1"] { opacity:.45; background:repeating-linear-gradient(
|
|
130
|
+
45deg, var(--line) 0 4px, transparent 4px 8px); color:var(--muted); }
|
|
131
|
+
.tl-rounds span { position:absolute; top:0; font-family:var(--mono); font-size:10.5px;
|
|
132
|
+
letter-spacing:.1em; text-transform:uppercase; color:var(--muted);
|
|
133
|
+
border-left:1px solid var(--line); padding-left:7px; }
|
|
134
|
+
.lane { display:grid; grid-template-columns:74px 1fr; gap:12px; align-items:center; }
|
|
135
|
+
.lane .who { font-family:var(--mono); font-size:12px; display:flex; align-items:center; gap:7px; }
|
|
136
|
+
.lane .who .sw { width:9px; height:9px; border-radius:2px; }
|
|
137
|
+
.track { position:relative; height:26px; background:var(--surface-alt); border-radius:3px; }
|
|
138
|
+
/* min-width so a span that is real but very short — or has not finished and
|
|
139
|
+
therefore has no width at all — still paints something hoverable. */
|
|
140
|
+
.seg { position:absolute; top:0; height:26px; border-radius:3px; display:flex; align-items:center;
|
|
141
|
+
min-width:4px; padding:0 7px; font-family:var(--mono); font-size:10.5px; color:#fff;
|
|
142
|
+
white-space:nowrap; overflow:hidden; }
|
|
143
|
+
/* Still running: striped rather than solid, and never flush to the right edge. */
|
|
144
|
+
.seg[data-open="1"] { min-width:10px; margin-left:-5px;
|
|
145
|
+
background-image:repeating-linear-gradient(115deg,rgba(255,255,255,.30) 0 4px,transparent 4px 8px); }
|
|
146
|
+
.seg[data-a="codex"] { background:var(--codex); }
|
|
147
|
+
.seg[data-a="droid"] { background:var(--droid); }
|
|
148
|
+
.seg[data-a="main"] { background:var(--main-hue); }
|
|
149
|
+
.tl-foot { font-family:var(--mono); font-size:11.5px; color:var(--muted); }
|
|
150
|
+
|
|
151
|
+
/* rounds */
|
|
152
|
+
.rgrid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; }
|
|
153
|
+
.rcard { background:var(--surface); border:1px solid var(--line); border-radius:4px; box-shadow:var(--shadow);
|
|
154
|
+
overflow:hidden; display:flex; flex-direction:column; }
|
|
155
|
+
.rcard header { padding:11px 15px; background:var(--surface-alt); border-bottom:1px solid var(--line-soft);
|
|
156
|
+
display:flex; align-items:baseline; gap:10px; }
|
|
157
|
+
.rcard header b { font-family:var(--mono); font-size:13px; }
|
|
158
|
+
.rcard header .sha { font-family:var(--mono); font-size:11.5px; color:var(--muted); margin-left:auto; }
|
|
159
|
+
.verd { display:flex; gap:10px; padding:11px 15px; border-top:1px solid var(--line-soft); align-items:flex-start; }
|
|
160
|
+
.verd:first-of-type { border-top:0; }
|
|
161
|
+
.verd .ag { font-family:var(--mono); font-size:11.5px; display:flex; align-items:center; gap:6px; min-width:76px; }
|
|
162
|
+
.verd .ag .sw { width:8px; height:8px; border-radius:2px; }
|
|
163
|
+
.verd .txt { font-size:13.2px; color:var(--ink-2); }
|
|
164
|
+
.verd .txt b { color:var(--ink); }
|
|
165
|
+
.verd[data-s="clean"] .txt b { color:var(--ok); }
|
|
166
|
+
.verd[data-s="found"] .txt b { color:var(--warn); }
|
|
167
|
+
|
|
168
|
+
/* exchanges */
|
|
169
|
+
.filters { display:flex; gap:7px; flex-wrap:wrap; }
|
|
170
|
+
.chip { font-family:var(--mono); font-size:11px; letter-spacing:.06em; text-transform:uppercase;
|
|
171
|
+
padding:5px 11px; border-radius:999px; border:1px solid var(--line); background:var(--surface);
|
|
172
|
+
color:var(--muted); cursor:pointer; }
|
|
173
|
+
.chip:hover { border-color:var(--accent); color:var(--accent); }
|
|
174
|
+
.chip:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
175
|
+
.chip[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--surface); }
|
|
176
|
+
:root[data-theme="dark"] .chip[aria-pressed="true"] { color:#0E1216; }
|
|
177
|
+
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]) .chip[aria-pressed="true"]{ color:#0E1216; } }
|
|
178
|
+
|
|
179
|
+
/* exchange — a terminal transcript. Deliberately dark in both themes: this is
|
|
180
|
+
agent output, and it reads as a session rather than a document. */
|
|
181
|
+
.term, .io { --t-bg:#0F1419; --t-fg:#C6CFD8; --t-dim:#6B7885; --t-line:#212A33;
|
|
182
|
+
--t-codex:#7FB0D0; --t-droid:#63C4B0; --t-claude:#B49AD4;
|
|
183
|
+
--t-ok:#6FC49B; --t-warn:#D9A857; --t-bad:#E08076;
|
|
184
|
+
background:var(--t-bg); border:1px solid var(--t-line); border-radius:6px;
|
|
185
|
+
overflow:hidden; box-shadow:var(--shadow); font-family:var(--mono); }
|
|
186
|
+
.term-bar { display:flex; align-items:center; gap:8px; padding:9px 13px;
|
|
187
|
+
background:#0A0E12; border-bottom:1px solid var(--t-line); }
|
|
188
|
+
.term-bar .dot { width:10px; height:10px; border-radius:50%; }
|
|
189
|
+
.term-bar .dot.r { background:#E0655B; } .term-bar .dot.y { background:#D9A857; }
|
|
190
|
+
.term-bar .dot.g { background:#59B87F; }
|
|
191
|
+
.term-bar .t { margin-left:8px; font-size:11.5px; color:var(--t-dim); }
|
|
192
|
+
.term-body { padding:14px 16px 16px; font-size:12.8px; line-height:1.62; color:var(--t-fg);
|
|
193
|
+
overflow-x:auto; }
|
|
194
|
+
.term .cwd { color:var(--t-droid); }
|
|
195
|
+
.term .branch { color:var(--t-claude); }
|
|
196
|
+
.term .cmdline { margin:0 0 12px; white-space:pre-wrap; word-break:break-all; }
|
|
197
|
+
.term .cmdline .p { color:var(--t-ok); margin-right:7px; }
|
|
198
|
+
.term .cmdline .c { color:var(--t-fg); }
|
|
199
|
+
|
|
200
|
+
.xb { border-top:1px solid var(--t-line); padding:13px 0 4px; }
|
|
201
|
+
.xb:first-of-type { border-top:0; padding-top:2px; }
|
|
202
|
+
.xb-head { display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; margin-bottom:9px; }
|
|
203
|
+
.xb-head .claim { color:#E7EDF3; font-weight:600; flex:1 1 300px; }
|
|
204
|
+
.xb-head .meta { color:var(--t-dim); font-size:11.5px; width:100%; }
|
|
205
|
+
.tag { font-size:10.5px; letter-spacing:.09em; text-transform:uppercase; padding:2px 8px;
|
|
206
|
+
border-radius:3px; white-space:nowrap; }
|
|
207
|
+
.tag[data-r="accepted"] { color:var(--t-ok); background:rgba(111,196,155,.13); }
|
|
208
|
+
.tag[data-r="deferred"] { color:var(--t-warn); background:rgba(217,168,87,.13); }
|
|
209
|
+
.tag[data-r="rejected"] { color:var(--t-bad); background:rgba(224,128,118,.13); }
|
|
210
|
+
.tag[data-r="open"] { color:var(--t-codex); background:rgba(127,176,208,.13); }
|
|
211
|
+
|
|
212
|
+
.ln { display:grid; grid-template-columns:82px 78px minmax(0,1fr); gap:10px; padding:5px 0;
|
|
213
|
+
align-items:start; }
|
|
214
|
+
.ln .at { color:var(--t-dim); font-size:11.5px; line-height:1.35; }
|
|
215
|
+
.ln .at b { display:block; font-weight:500; color:#8A99A8; }
|
|
216
|
+
.ln .at i { display:block; font-style:normal; font-size:10.5px; opacity:.72; }
|
|
217
|
+
.ln .nm { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
|
|
218
|
+
.ln .nm svg { width:12px; height:12px; }
|
|
219
|
+
.ln[data-who="codex"] .nm { color:var(--t-codex); }
|
|
220
|
+
.ln[data-who="droid"] .nm { color:var(--t-droid); }
|
|
221
|
+
.ln[data-who="claude"] .nm { color:var(--t-claude); }
|
|
222
|
+
.ln .say { white-space:pre-wrap; }
|
|
223
|
+
.ln .say strong { color:#EDF2F7; font-weight:600; }
|
|
224
|
+
@media (max-width:720px){ .ln{ grid-template-columns:1fr; gap:2px; } }
|
|
225
|
+
|
|
226
|
+
.term .q { margin:7px 0 3px 0; padding:8px 11px; background:#0A0E12; border-left:2px solid var(--t-line);
|
|
227
|
+
color:#9FB0BF; font-size:12px; white-space:pre; overflow-x:auto; }
|
|
228
|
+
.term .ask { margin:7px 0 3px; padding-left:10px; border-left:2px solid var(--t-codex); color:#DCE5EC; }
|
|
229
|
+
.term .out { margin:9px 0 2px; font-size:12.2px; }
|
|
230
|
+
.term .out[data-r="accepted"] { color:var(--t-ok); }
|
|
231
|
+
.term .out[data-r="deferred"] { color:var(--t-warn); }
|
|
232
|
+
.term .out[data-r="rejected"] { color:var(--t-bad); }
|
|
233
|
+
.term .out b { text-transform:uppercase; letter-spacing:.08em; font-size:11px; }
|
|
234
|
+
|
|
235
|
+
/* What went in and what came out, as one session. A clean verdict still has a
|
|
236
|
+
report, and hiding it was why a converged run looked like nothing happened. */
|
|
237
|
+
.io { background:var(--t-bg); border:1px solid var(--t-line); border-radius:6px;
|
|
238
|
+
overflow:hidden; box-shadow:var(--shadow); font-family:var(--mono); }
|
|
239
|
+
.io-body { padding:13px 16px 18px; font-size:12.6px; line-height:1.6; color:var(--t-fg);
|
|
240
|
+
max-height:640px; overflow:auto; }
|
|
241
|
+
|
|
242
|
+
/* One column per reviewer. They are side by side to make the independence
|
|
243
|
+
legible at a glance; below the breakpoint they stack, which is the same
|
|
244
|
+
claim in one dimension. */
|
|
245
|
+
/* Full width, one at a time — a report needs the room to be readable. */
|
|
246
|
+
.convo-grid { display:block; }
|
|
247
|
+
/* The selected tab has to be unmistakable. When it was only tinted, clicking
|
|
248
|
+
the already-selected first tab looked like a dead button rather than a
|
|
249
|
+
no-op — the state was there, it just was not legible enough. */
|
|
250
|
+
.convo-switch { display:flex; gap:6px; flex-wrap:wrap; margin:0 0 10px; }
|
|
251
|
+
.convo-switch .pr-chip { font-weight:600; }
|
|
252
|
+
/* Literal, not var(--t-bg): that variable is scoped to .term/.io, so on a
|
|
253
|
+
chip it resolves to nothing and left white text on a transparent
|
|
254
|
+
background — invisible, and only a screenshot showed it. */
|
|
255
|
+
.convo-switch .pr-chip[aria-pressed="true"] {
|
|
256
|
+
background:#0F1419; color:#fff; border-color:#0F1419;
|
|
257
|
+
box-shadow:inset 0 -2px 0 var(--accent); }
|
|
258
|
+
.convo-switch .pr-chip[aria-pressed="true"]:hover { color:#fff; }
|
|
259
|
+
.convo-switch .pr-chip[aria-pressed="false"] { opacity:.7; }
|
|
260
|
+
.convo-note { margin:0 0 10px; font-size:12.5px; color:var(--muted); }
|
|
261
|
+
.convo .io-body { max-height:none; }
|
|
262
|
+
/* A long unbroken report must scroll inside its own column rather than
|
|
263
|
+
widening the grid and pushing the other conversation off-screen. */
|
|
264
|
+
.convo .stream { white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
265
|
+
|
|
266
|
+
/* Findings carry the same grouping as the transcripts above: one block per
|
|
267
|
+
reviewer, so a claim is always read next to who raised it. */
|
|
268
|
+
.xgroup { margin:0 0 6px; }
|
|
269
|
+
.xgroup + .xgroup { margin-top:18px; padding-top:4px; border-top:1px dashed var(--t-line); }
|
|
270
|
+
.xgroup > .rule { margin-bottom:8px; }
|
|
271
|
+
.io .cmdline { margin:0 0 4px; white-space:pre-wrap; word-break:break-all; }
|
|
272
|
+
.io .cmdline .p { color:var(--t-ok); margin-right:7px; }
|
|
273
|
+
|
|
274
|
+
/* A divider that carries its own metadata, the way a TUI labels a pane. */
|
|
275
|
+
.rule { display:flex; align-items:center; gap:10px; margin:20px 0 9px;
|
|
276
|
+
font-size:10.5px; letter-spacing:.09em; text-transform:uppercase; color:var(--t-dim); }
|
|
277
|
+
.rule:first-child { margin-top:4px; }
|
|
278
|
+
.rule i { flex:1 1 auto; height:1px; background:var(--t-line); }
|
|
279
|
+
.rule .lbl { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
|
|
280
|
+
.rule .lbl svg { width:12px; height:12px; }
|
|
281
|
+
.rule[data-who="codex"] .lbl { color:var(--t-codex); }
|
|
282
|
+
.rule[data-who="droid"] .lbl { color:var(--t-droid); }
|
|
283
|
+
.rule[data-who="stdin"] .lbl { color:var(--t-claude); }
|
|
284
|
+
.rule .rt { color:var(--t-dim); white-space:nowrap; }
|
|
285
|
+
.rule .rt b { font-weight:600; }
|
|
286
|
+
.rule .rt b[data-s="clean"] { color:var(--t-ok); }
|
|
287
|
+
.rule .rt b[data-s="found"] { color:var(--t-warn); }
|
|
288
|
+
.rule .rt b[data-s="error"] { color:var(--t-bad); }
|
|
289
|
+
|
|
290
|
+
.io .stream { margin:0; white-space:pre-wrap; overflow-x:auto; color:var(--t-fg);
|
|
291
|
+
border-left:2px solid var(--t-line); padding:2px 0 2px 13px; }
|
|
292
|
+
.io .stream.raw { white-space:pre; }
|
|
293
|
+
.io .stream.stdin { color:#9FB0BF; }
|
|
294
|
+
|
|
295
|
+
/* Terminal-style toggles: they read as flags, not as buttons. */
|
|
296
|
+
.tabs { display:flex; gap:6px; margin:8px 0 0 13px; }
|
|
297
|
+
.tabs button { font-family:var(--mono); font-size:10.5px; letter-spacing:.05em; cursor:pointer;
|
|
298
|
+
padding:2px 8px; border-radius:2px; border:1px solid var(--t-line);
|
|
299
|
+
background:transparent; color:var(--t-dim); }
|
|
300
|
+
.tabs button:hover { border-color:var(--t-codex); color:var(--t-codex); }
|
|
301
|
+
.tabs button:focus-visible { outline:2px solid var(--t-codex); outline-offset:2px; }
|
|
302
|
+
.tabs button[aria-pressed="true"] { border-color:var(--t-ok); color:var(--t-ok); }
|
|
303
|
+
.io .fold { cursor:pointer; }
|
|
304
|
+
.io .fold:focus-visible { outline:2px solid var(--t-codex); outline-offset:2px; }
|
|
305
|
+
|
|
306
|
+
/* shipped */
|
|
307
|
+
.ship { display:grid; grid-template-columns:repeat(auto-fit,minmax(158px,1fr)); gap:1px;
|
|
308
|
+
background:var(--line-soft); border:1px solid var(--line); }
|
|
309
|
+
.ship div { background:var(--surface); padding:15px 17px; display:flex; flex-direction:column; gap:3px; }
|
|
310
|
+
.ship .n { font-family:var(--mono); font-size:24px; font-variant-numeric:tabular-nums; letter-spacing:-.02em; }
|
|
311
|
+
.ship .k { font-family:var(--mono); font-size:10px; letter-spacing:.11em; text-transform:uppercase; color:var(--muted); }
|
|
312
|
+
.ship .s { font-size:12.4px; color:var(--muted); }
|
|
313
|
+
footer { border-top:1px solid var(--line); padding-top:16px; color:var(--muted); font-size:12.6px; max-width:78ch; }
|
|
314
|
+
@media (prefers-reduced-motion: reduce){ *{ transition:none !important; } }
|
|
315
|
+
|
|
316
|
+
/* ── Conversation ───────────────────────────────────────────────────
|
|
317
|
+
Two speakers, two sides. claude always left, the reviewer always right,
|
|
318
|
+
so which side a bubble sits on tells you who is talking before you read
|
|
319
|
+
a word of it. One column per reviewer: codex and agy are independent
|
|
320
|
+
conversations and must never be shown merged, or their agreement stops
|
|
321
|
+
being evidence of anything. */
|
|
322
|
+
.convo-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:0 0 14px; }
|
|
323
|
+
.convo-tabs { display:flex; gap:6px; flex-wrap:wrap; min-width:0; }
|
|
324
|
+
.convo-bar .spacer { flex:1 1 auto; }
|
|
325
|
+
/* Side by side stopped scaling at three: a fourth reviewer either overflowed
|
|
326
|
+
or squeezed every column below the width a report needs to be readable in.
|
|
327
|
+
One thread at full width is the default, and stacking is a deliberate
|
|
328
|
+
choice for comparing — never something the layout does to you because a
|
|
329
|
+
fifth agent was configured. */
|
|
330
|
+
.convo-grid { display:flex; flex-direction:column; gap:18px; }
|
|
331
|
+
/* A per-reviewer count on the tab, so an unread thread is visible without
|
|
332
|
+
opening it — the reason to run five reviewers is to notice the one that
|
|
333
|
+
found something. */
|
|
334
|
+
.convo-tabs .pr-chip .n {
|
|
335
|
+
font:600 10px/1 var(--mono); color:var(--muted);
|
|
336
|
+
padding:2px 5px; border-radius:20px; background:var(--surface-alt); margin-left:2px;
|
|
337
|
+
}
|
|
338
|
+
.convo-tabs .pr-chip[aria-pressed="true"] .n { background:rgba(255,255,255,.22); color:inherit; }
|
|
339
|
+
.convo-tabs .pr-chip .n[data-live="1"] { color:var(--warn); }
|
|
340
|
+
.stack-btn {
|
|
341
|
+
font:600 11px/1 var(--sans); color:var(--ink-2); background:var(--surface);
|
|
342
|
+
border:1px solid var(--line); border-radius:20px; padding:7px 12px; cursor:pointer;
|
|
343
|
+
}
|
|
344
|
+
.stack-btn[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
|
|
345
|
+
/* Stacked: every thread visible, each collapsible, so one long conversation
|
|
346
|
+
does not bury the four below it. */
|
|
347
|
+
.convo > header { cursor:default; }
|
|
348
|
+
.convo-grid[data-stack="1"] .convo > header { cursor:pointer; }
|
|
349
|
+
.convo .tw { margin-right:2px; font-size:10px; color:var(--muted); display:none; }
|
|
350
|
+
.convo-grid[data-stack="1"] .convo .tw { display:inline; }
|
|
351
|
+
.convo[data-open="0"] .msgs { display:none; }
|
|
352
|
+
.convo {
|
|
353
|
+
border:1px solid var(--line); border-radius:12px; background:var(--surface);
|
|
354
|
+
box-shadow:var(--shadow); display:flex; flex-direction:column; min-width:0;
|
|
355
|
+
}
|
|
356
|
+
.convo > header {
|
|
357
|
+
display:flex; align-items:center; gap:8px; padding:10px 14px;
|
|
358
|
+
border-bottom:1px solid var(--line-soft); font:600 13px/1.3 var(--sans);
|
|
359
|
+
}
|
|
360
|
+
.convo > header .who { color:var(--ink); }
|
|
361
|
+
.convo > header .st { margin-left:auto; font:500 11px/1 var(--mono); color:var(--muted); }
|
|
362
|
+
.convo > header .d { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; }
|
|
363
|
+
.convo > header .d[data-live="1"] { background:var(--warn); animation:blip 1.4s ease-in-out infinite; }
|
|
364
|
+
@keyframes blip { 0%,100%{opacity:1} 50%{opacity:.25} }
|
|
365
|
+
/* The thread is what this page exists to show, so it gets real height: tall
|
|
366
|
+
enough to read in, with a floor so a short conversation still reads as the
|
|
367
|
+
main surface rather than a strip under the timeline. */
|
|
368
|
+
.msgs { padding:14px 16px; display:flex; flex-direction:column; gap:10px;
|
|
369
|
+
overflow-y:auto; max-height:82vh; min-height:min(34em, 58vh); }
|
|
370
|
+
.convo-grid[data-stack="1"] .msgs { max-height:56vh; min-height:0; }
|
|
371
|
+
/* Wider than before: bubbles now carry the reviewer's report in full, and a
|
|
372
|
+
narrow column turns a report into a ribbon. */
|
|
373
|
+
.msg { max-width:min(88%, 940px); display:flex; flex-direction:column; gap:4px; min-width:0; }
|
|
374
|
+
/* Popped out: the thread and nothing else, filling the window. Same fold and
|
|
375
|
+
same live stream as the embedded view — only the frame around it differs. */
|
|
376
|
+
/* Descendant, not child: the page nests everything inside div.wrap, so a
|
|
377
|
+
`body > header` selector matches nothing at all. */
|
|
378
|
+
body[data-view="conversation"] .wrap > header,
|
|
379
|
+
body[data-view="conversation"] section:not(#convo-sec) { display:none; }
|
|
380
|
+
body[data-view="conversation"] #convo-sec > h2,
|
|
381
|
+
body[data-view="conversation"] #c-note { display:none; }
|
|
382
|
+
body[data-view="conversation"] #convo-pop { display:none; }
|
|
383
|
+
body[data-view="conversation"] { padding:0; }
|
|
384
|
+
body[data-view="conversation"] .wrap { max-width:none; width:100%; margin:0;
|
|
385
|
+
padding:0; height:100vh; display:flex; flex-direction:column; }
|
|
386
|
+
body[data-view="conversation"] #convo-sec { margin:0; padding:12px 16px 16px;
|
|
387
|
+
height:100vh; display:flex; flex-direction:column; gap:10px; }
|
|
388
|
+
body[data-view="conversation"] .convo-grid { flex:1; min-height:0; }
|
|
389
|
+
body[data-view="conversation"] .convo { height:100%; }
|
|
390
|
+
body[data-view="conversation"] .msgs { max-height:none; min-height:0; flex:1; }
|
|
391
|
+
.msg[data-side="l"] { align-self:flex-start; }
|
|
392
|
+
.msg[data-side="r"] { align-self:flex-end; }
|
|
393
|
+
.msg .nm { font:600 11px/1 var(--sans); color:var(--muted); padding:0 4px; }
|
|
394
|
+
.msg .nm time {
|
|
395
|
+
font:400 10px/1 var(--mono); color:var(--muted); opacity:.72; margin-left:6px;
|
|
396
|
+
cursor:help; letter-spacing:.02em;
|
|
397
|
+
}
|
|
398
|
+
.msg .nm time:hover { opacity:1; }
|
|
399
|
+
.msg[data-side="r"] .nm { text-align:right; }
|
|
400
|
+
.bub {
|
|
401
|
+
/* Named so a scrolling .tall inside can fade to its own bubble's colour,
|
|
402
|
+
which differs per side. */
|
|
403
|
+
--bub-bg:var(--surface-alt);
|
|
404
|
+
border:1px solid var(--line); border-radius:12px; padding:9px 12px;
|
|
405
|
+
font:400 13px/1.55 var(--sans); color:var(--ink); white-space:pre-wrap;
|
|
406
|
+
overflow-wrap:anywhere; background:var(--bub-bg);
|
|
407
|
+
}
|
|
408
|
+
.msg[data-side="l"] .bub { --bub-bg:var(--main-soft);
|
|
409
|
+
border-bottom-left-radius:4px; border-color:var(--main-hue); }
|
|
410
|
+
.msg[data-side="r"] .bub { border-bottom-right-radius:4px; }
|
|
411
|
+
.bub .hd { display:block; font:600 12px/1.4 var(--sans); margin-bottom:3px; }
|
|
412
|
+
.bub .loc { font:500 11px/1.4 var(--mono); color:var(--muted); }
|
|
413
|
+
/* Names the finding a verdict answers, now that findings are not
|
|
414
|
+
bubbles of their own. Quiet: the reviewer already said it in full. */
|
|
415
|
+
.bub .re { display:block; font:500 11px/1.45 var(--sans); color:var(--muted);
|
|
416
|
+
border-left:2px solid var(--line); padding-left:7px; margin-bottom:5px; }
|
|
417
|
+
.bub .re:last-child { margin-bottom:0; }
|
|
418
|
+
/* A re-argued claim. Deliberately quiet and textless: the words are in the
|
|
419
|
+
reply bubble above it, and this only marks that the argument continues. */
|
|
420
|
+
.bub[data-k="rebuttal"] { border-left:3px solid var(--warn); }
|
|
421
|
+
.bub[data-k="verdict"] { border-left:3px solid var(--accent); }
|
|
422
|
+
.bub[data-k="verdict"][data-v="accepted"] { border-left-color:var(--ok); background:var(--ok-soft); }
|
|
423
|
+
.bub[data-k="verdict"][data-v="rejected"] { border-left-color:var(--bad); background:var(--bad-soft); }
|
|
424
|
+
.bub[data-k="verdict"][data-v="deferred"] { border-left-color:var(--warn); background:var(--warn-soft); }
|
|
425
|
+
.bub[data-k="commit"] { font:500 12px/1.5 var(--mono); background:transparent; border-style:dashed; }
|
|
426
|
+
.bub[data-k="waiting"] {
|
|
427
|
+
font:500 12px/1.5 var(--sans); color:var(--muted);
|
|
428
|
+
background:transparent; border-style:dashed;
|
|
429
|
+
}
|
|
430
|
+
.bub[data-k="waiting"] .tick { font-family:var(--mono); color:var(--ink-2); }
|
|
431
|
+
.bub[data-k="waiting"] .q { color:var(--warn); }
|
|
432
|
+
.bub[data-k="waiting"]::after {
|
|
433
|
+
content:"●"; margin-left:6px; font-size:9px; color:var(--warn);
|
|
434
|
+
animation:blip 1.4s ease-in-out infinite;
|
|
435
|
+
}
|
|
436
|
+
.bub[data-k="streaming"] { font:400 12px/1.5 var(--mono); color:var(--ink-2); }
|
|
437
|
+
.bub[data-k="streaming"]::after {
|
|
438
|
+
content:"▍"; animation:blip 1s steps(2) infinite; color:var(--accent);
|
|
439
|
+
}
|
|
440
|
+
/* A long message keeps all of its text and gives up height instead. The
|
|
441
|
+
inset shadows are the affordance: they say "there is more above/below"
|
|
442
|
+
without a control to click. */
|
|
443
|
+
.bub .tall { max-height:26em; overflow-y:auto; overscroll-behavior:contain;
|
|
444
|
+
scrollbar-width:thin; border-radius:3px;
|
|
445
|
+
background:
|
|
446
|
+
linear-gradient(var(--bub-bg) 30%, transparent) top / 100% 1.2em no-repeat,
|
|
447
|
+
linear-gradient(transparent, var(--bub-bg) 70%) bottom / 100% 1.2em no-repeat,
|
|
448
|
+
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.10), transparent) top / 100% .5em no-repeat,
|
|
449
|
+
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.10), transparent) bottom / 100% .5em no-repeat;
|
|
450
|
+
background-attachment:local, local, scroll, scroll; }
|
|
451
|
+
.bub .tall:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
452
|
+
.convo[data-a="agy"] > header .who, .msg[data-side="r"] .nm { color:var(--muted); }
|
|
453
|
+
.convo-tabs .pr-chip .ic, .convo > header .ic, .msg .nm .ic {
|
|
454
|
+
display:inline-flex; align-items:center; vertical-align:-2px; margin-right:4px;
|
|
455
|
+
}
|
|
456
|
+
.msg .nm .ic svg { width:11px; height:11px; }
|
|
457
|
+
/* Inline-flex, not flex: a full-width flex row on the right side rendered as
|
|
458
|
+
an empty box beside the name. */
|
|
459
|
+
.msg[data-side="r"] .nm { display:inline-flex; align-items:center; align-self:flex-end; }
|
|
460
|
+
.msg[data-side="l"] .nm { display:inline-flex; align-items:center; align-self:flex-start; }
|
|
461
|
+
.convo > header .who { display:inline-flex; align-items:center; gap:2px; }
|
|
462
|
+
.convo > header .sw { margin:0 4px; color:var(--muted); font-weight:400; }
|
|
463
|
+
.convo .empty { padding:26px 14px; text-align:center; font:400 13px/1.5 var(--sans); color:var(--muted); }
|
|
464
|
+
</style>
|
|
465
|
+
|
|
466
|
+
<div class="wrap">
|
|
467
|
+
|
|
468
|
+
<header class="hd">
|
|
469
|
+
<div class="switcher" id="switcher"></div>
|
|
470
|
+
<div class="kicker" id="t-kicker"></div>
|
|
471
|
+
<h1 id="t-title"></h1>
|
|
472
|
+
<div class="meta" id="t-meta"></div>
|
|
473
|
+
<p class="note" id="t-summary"></p>
|
|
474
|
+
</header>
|
|
475
|
+
|
|
476
|
+
<section>
|
|
477
|
+
<h2>How the run actually went</h2>
|
|
478
|
+
<p class="note">Reviewers run concurrently; the main agent works between rounds. Bar width is real elapsed time.</p>
|
|
479
|
+
<div class="tl"><div class="tl-inner" id="tl"></div></div>
|
|
480
|
+
<p class="tl-foot" id="tlfoot"></p>
|
|
481
|
+
</section>
|
|
482
|
+
|
|
483
|
+
<section>
|
|
484
|
+
<h2>Round by round</h2>
|
|
485
|
+
<div class="rgrid" id="rounds"></div>
|
|
486
|
+
</section>
|
|
487
|
+
|
|
488
|
+
<section id="convo-sec">
|
|
489
|
+
<h2>The conversation</h2>
|
|
490
|
+
<p class="note" id="c-note">Claude on the left, the reviewer on the right. Each reviewer is a
|
|
491
|
+
separate thread — they never see each other's findings, which is the only reason running more
|
|
492
|
+
than one counts as evidence.</p>
|
|
493
|
+
<div class="convo-bar">
|
|
494
|
+
<div class="convo-tabs" id="convo-tabs"></div>
|
|
495
|
+
<span class="spacer"></span>
|
|
496
|
+
<button class="stack-btn" id="convo-stack" aria-pressed="false">stack all</button>
|
|
497
|
+
<a class="stack-btn" id="convo-pop" href="#" target="_blank" rel="noopener"
|
|
498
|
+
title="Open the conversation in its own window">pop out ↗</a>
|
|
499
|
+
</div>
|
|
500
|
+
<div class="convo-grid" id="convo-grid"></div>
|
|
501
|
+
</section>
|
|
502
|
+
|
|
503
|
+
<section>
|
|
504
|
+
<h2>The exchange</h2>
|
|
505
|
+
<p class="note" id="x-note"></p>
|
|
506
|
+
<div class="filters" id="filters"></div>
|
|
507
|
+
<div id="thread" style="display:flex;flex-direction:column;gap:16px"></div>
|
|
508
|
+
</section>
|
|
509
|
+
|
|
510
|
+
<section id="ship-sec">
|
|
511
|
+
<h2>What shipped</h2>
|
|
512
|
+
<div class="ship" id="ship"></div>
|
|
513
|
+
</section>
|
|
514
|
+
|
|
515
|
+
<footer id="foot"></footer>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<script>
|
|
519
|
+
let RUN = {
|
|
520
|
+
target: {
|
|
521
|
+
repo: "acme/worker-pool", id: "#128",
|
|
522
|
+
url: "https://github.com/acme/worker-pool/pull/128",
|
|
523
|
+
title: "Grow the CreateSandbox retry wait on repeated failures",
|
|
524
|
+
branch: "fix/sandbox-manager-create-backoff", trunk: "master",
|
|
525
|
+
diffstat: "2 files · +418 −13", state: "merged", stateNote: "merged · converged on ae473ab",
|
|
526
|
+
summary: "Two reviewers — codex and droid — read each pushed commit independently. The main agent triages what they find, fixes what survives verification, answers every finding including the ones it rejects, and pushes again. Three rounds until both reported nothing new."
|
|
527
|
+
},
|
|
528
|
+
totalMin: 100,
|
|
529
|
+
lanes: [
|
|
530
|
+
{ who:"codex", label:"codex", segs:[ {r:1,s:0,e:18},{r:2,s:40,e:58},{r:3,s:78,e:96} ] },
|
|
531
|
+
{ who:"droid", label:"droid", segs:[ {r:1,s:0,e:2},{r:2,s:40,e:42},{r:3,s:78,e:80} ] },
|
|
532
|
+
{ who:"main", label:"claude", segs:[ {r:1,s:18,e:40,t:"triage · fix · push"},
|
|
533
|
+
{r:2,s:58,e:78,t:"triage · fix · push"},
|
|
534
|
+
{r:3,s:96,e:100,t:"merge prep"} ] }
|
|
535
|
+
],
|
|
536
|
+
marks: [ {at:0,l:"round 1"}, {at:40,l:"round 2"}, {at:78,l:"round 3"} ],
|
|
537
|
+
tlfoot:"codex ~18 min per round, droid ~2 min. Every round's wall clock is set by the slower reviewer — running them in series would have roughly doubled the run.",
|
|
538
|
+
xnote:"Every finding gets a reply. The rejected one matters most — it caught a bug in the prompt, not the code.",
|
|
539
|
+
rounds: [
|
|
540
|
+
{ n:1, sha:"bf94454", verdicts:[
|
|
541
|
+
{ a:"codex", s:"found", t:"<b>1 finding</b> — tests derive their bounds from the constants they check" },
|
|
542
|
+
{ a:"droid", s:"clean", t:"<b>NO NEW FINDINGS</b>" } ] },
|
|
543
|
+
{ n:2, sha:"6901882", verdicts:[
|
|
544
|
+
{ a:"codex", s:"found", t:"<b>2 findings</b> — jitter is deletable; cancellation test is probabilistic" },
|
|
545
|
+
{ a:"droid", s:"clean", t:"<b>NO NEW FINDINGS</b>" } ] },
|
|
546
|
+
{ n:3, sha:"ae473ab", verdicts:[
|
|
547
|
+
{ a:"codex", s:"clean", t:"<b>NO NEW FINDINGS</b>" },
|
|
548
|
+
{ a:"droid", s:"clean", t:"<b>NO NEW FINDINGS</b>" } ] }
|
|
549
|
+
],
|
|
550
|
+
exchanges: [
|
|
551
|
+
{ id:"X1", round:2, res:"accepted", loc:"main_test.go:2237 · low · found by codex",
|
|
552
|
+
claim:"Jitter could be deleted and every test would still pass",
|
|
553
|
+
outcome:"Fixed. Test_createBackoff now asserts jitter shortens and varies across 200 draws — verified to fail when jitter is removed.",
|
|
554
|
+
turns:[
|
|
555
|
+
{ who:"codex", ts:"round 2 · T+18m", body:"Every assertion accepts the nominal delay itself. Replacing createBackoff's final expression with <strong>return delay</strong> removes jitter entirely yet passes all new backoff and worker tests. The sampling assertion only enforces the ceiling, not that waits are ever shortened or varied." },
|
|
556
|
+
{ who:"claude", ts:"round 2 · T+41m", body:"Reproduced — and my first attempt to reproduce it was itself wrong. Deleting the jitter expression orphaned the <strong>math/rand/v2</strong> import, so the FAIL I saw was a compile error, not an assertion. Kept the import used and re-ran: every test passed. You were right.",
|
|
557
|
+
quote:"_ = rand.Float64()\nreturn delay // jitter gone\nall backoff tests: PASS", ask:"Fixed by asserting jitter actually moves. Does that close it?" },
|
|
558
|
+
{ who:"codex", ts:"round 3 · T+96m", body:"NO NEW FINDINGS." } ] },
|
|
559
|
+
|
|
560
|
+
{ id:"X2", round:2, res:"accepted", loc:"main_test.go:2476 · low · found by codex",
|
|
561
|
+
claim:"The cancellation test could pass by luck",
|
|
562
|
+
outcome:"Fixed. The wait is now stubbed to report an unfinished wait with the context still live, so a looping worker has no race to hide behind. Fails deterministically in 0.00s.",
|
|
563
|
+
turns:[
|
|
564
|
+
{ who:"codex", ts:"round 2 · T+18m", body:"If the worker regresses from <strong>return</strong> to <strong>continue</strong>, its next select can still choose ctx.Done() before a buffered job. All 20 runs can therefore report exactly one call and pass incorrectly — roughly a 1-in-1,048,576 false pass." },
|
|
565
|
+
{ who:"claude", ts:"round 2 · T+52m", body:"Accepted. Made it deterministic rather than merely improbable. Writing it surfaced a second problem of my own: under regression the test <strong>hung</strong> instead of failing — the worker drained the buffered channel then blocked on an open, empty one. Closing the channel makes it fail fast.",
|
|
566
|
+
quote:"before: 20 runs, P(false pass) ≈ 2⁻²⁰\nafter: deterministic, fails in 0.00s",
|
|
567
|
+
ask:"A test that hangs on regression is worse than one that fails — it burns the CI timeout and reads as infra. Agreed?" },
|
|
568
|
+
{ who:"codex", ts:"round 3 · T+96m", body:"NO NEW FINDINGS." } ] },
|
|
569
|
+
|
|
570
|
+
{ id:"X3", round:1, res:"accepted", loc:"main_test.go:2223 · low · found by codex",
|
|
571
|
+
claim:"The tests validate their own constants",
|
|
572
|
+
outcome:"Fixed. Test_backoffLimits pins 1s / 60s / 0.2 / 60s independently, so a constant change has to be deliberate.",
|
|
573
|
+
turns:[
|
|
574
|
+
{ who:"codex", ts:"round 1 · T+18m", body:"The expected bounds are derived from createBackoffJitter and maxCreateBackoff. Changing jitter to 50% or lowering the cap to 48s leaves all new tests green despite violating the specified behaviour. Pin those contractual values independently." },
|
|
575
|
+
{ who:"claude", ts:"round 1 · T+33m", body:"Reproduced exactly as described — with the cap at 48s and jitter at 50%, every existing backoff test stayed green and only the new pinning test failed.",
|
|
576
|
+
quote:"cap 60s→48s, jitter 0.2→0.5\nother backoff tests: PASS\nTest_backoffLimits: FAIL" } ] },
|
|
577
|
+
|
|
578
|
+
{ id:"X4", round:1, res:"rejected", loc:"acquire_route_handler.go · info · raised by droid",
|
|
579
|
+
claim:"The diff also deletes IdlePoolMissReason and a response header",
|
|
580
|
+
outcome:"Rejected — an artifact of the diff base, caused by my prompt. The prompt now pins the merge-base, and droid confirmed against it.",
|
|
581
|
+
turns:[
|
|
582
|
+
{ who:"droid", ts:"round 1 · T+02m", body:"The diff also removes the IdlePoolMissReason type, the X-Assign-Missed-Reason response header, and ~80 lines of sanitization. This is a feature removal that changes the public API surface — a separate concern from the backoff change." },
|
|
583
|
+
{ who:"claude", ts:"round 1 · T+29m", body:"<strong>Rejected, and this one is my fault rather than yours.</strong> My prompt told you to run <strong>git diff origin/master</strong>. The trunk gained that commit after our branch point, so another commit's addition showed up inverted as our deletion.",
|
|
584
|
+
quote:"git diff origin/master → 8 files\ngit diff $(git merge-base …) → 2 files", ask:"Please re-check against the merge-base and confirm." },
|
|
585
|
+
{ who:"droid", ts:"round 1 · T+36m", body:"Confirmed. The diff against the merge-base touches exactly two files, and no mention of IdlePoolMissReason appears. The commit that added it landed on the trunk after the branch point. Purely an artifact of the diff base." } ] },
|
|
586
|
+
|
|
587
|
+
{ id:"X5", round:1, res:"deferred", loc:"main.go:289 · medium · found by codex",
|
|
588
|
+
claim:"Producer can over-reserve the in-flight count",
|
|
589
|
+
outcome:"Deferred by agreement. Pre-existing and untouched by this change; tracked as its own PR rather than widening this one.",
|
|
590
|
+
turns:[
|
|
591
|
+
{ who:"codex", ts:"round 1 · T+18m", body:"The producer reads the in-flight count, unlocks, calculates need, and only later locks again to add it. During manager replacement two producers can observe the same count and both reserve the full deficit. The mutex prevents a data race but not this check-then-act race." },
|
|
592
|
+
{ who:"claude", ts:"round 1 · T+33m", body:"<strong>Agree it is real. Not fixing it here</strong> — producer and the in-flight map are untouched by this change, which only edits the worker retry path. Widening the PR to cover it would mix two concerns.",
|
|
593
|
+
ask:"Do you agree it is (a) pre-existing and (b) not made materially more likely by this change? If the new backoff widens that window, say so and I will reconsider." },
|
|
594
|
+
{ who:"codex", ts:"round 1 · T+51m", body:"Accepted. The producer reservation code is byte-for-byte outside this commit's diff, so the issue is pre-existing. The new backoff does not materially widen the concurrent-producer window; cancellation-aware worker exit arguably reduces old/new manager overlap. I agree it belongs in a separate PR." } ] }
|
|
595
|
+
],
|
|
596
|
+
ship: [
|
|
597
|
+
{ n:"3", k:"rounds", s:"to convergence" },
|
|
598
|
+
{ n:"5", k:"exchanges", s:"3 fixed · 1 deferred · 1 rejected" },
|
|
599
|
+
{ n:"11", k:"turns", s:"every finding answered" },
|
|
600
|
+
{ n:"0", k:"prod bugs", s:"found in these 3 rounds" },
|
|
601
|
+
{ n:"3", k:"test defects", s:"tests that proved nothing" },
|
|
602
|
+
{ n:"~1.7h", k:"agent time", s:"~20 min per round" }
|
|
603
|
+
],
|
|
604
|
+
foot:"Verbatim from the run on #128. The main agent's turns include two of its own mistakes — a compile error misread as a caught regression, and a test that hung rather than failed — because both changed what shipped. Deferred findings became their own pull requests; rejected ones stay on the record with the evidence that settled them."
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// One mark per agent. Inline SVG on currentColor: no external asset, so the
|
|
608
|
+
// console works served locally and as a self-contained page, and both themes
|
|
609
|
+
// get the right contrast for free. Unknown agents fall back to a plain dot.
|
|
610
|
+
const ICONS = {
|
|
611
|
+
// Official marks (simple-icons, CC0 icon set; the marks themselves remain the
|
|
612
|
+
// trademarks of their owners and are used here only to identify the tools).
|
|
613
|
+
claude: '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
|
|
614
|
+
'<path fill="currentColor" d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>',
|
|
615
|
+
codex: '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
|
|
616
|
+
'<path fill="currentColor" d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>',
|
|
617
|
+
// agy is Google Antigravity. No mark is published in the CC0 icon set, so this
|
|
618
|
+
// is Antigravity's own shape rather than a borrowed one: an upward chevron
|
|
619
|
+
// over a horizon, which is what the product's own iconography reads as.
|
|
620
|
+
agy: '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
|
|
621
|
+
'<path fill="currentColor" d="M12 2.8 22 19.2h-4.36L12 9.9 6.36 19.2H2z"/>' +
|
|
622
|
+
'<path fill="currentColor" d="M7.6 21.2h8.8v1.9H7.6z" opacity=".55"/></svg>',
|
|
623
|
+
// Grok's own mark, taken from grok.com. Not the xAI slash logo — that is the
|
|
624
|
+
// company, this is the product doing the reviewing.
|
|
625
|
+
grok: '<svg viewBox="0 0 34 33" width="16" height="16" aria-hidden="true">' +
|
|
626
|
+
'<path fill="currentColor" d="M13.2371 21.0407L24.3186 12.8506C24.8619 12.4491 25.6384 12.6057 25.8973 13.2294C27.2597 16.5185 26.651 20.4712 23.9403 23.1851C21.2297 25.8989 17.4581 26.4941 14.0108 25.1386L10.2449 26.8843C15.6463 30.5806 22.2053 29.6665 26.304 25.5601C29.5551 22.3051 30.562 17.8683 29.6205 13.8673L29.629 13.8758C28.2637 7.99809 29.9647 5.64871 33.449 0.844576C33.5314 0.730667 33.6139 0.616757 33.6964 0.5L29.1113 5.09055V5.07631L13.2343 21.0436"/>' +
|
|
627
|
+
'<path fill="currentColor" d="M10.9503 23.0313C7.07343 19.3235 7.74185 13.5853 11.0498 10.2763C13.4959 7.82722 17.5036 6.82767 21.0021 8.2971L24.7595 6.55998C24.0826 6.07017 23.215 5.54334 22.2195 5.17313C17.7198 3.31926 12.3326 4.24192 8.67479 7.90126C5.15635 11.4239 4.0499 16.8403 5.94992 21.4622C7.36924 24.9165 5.04257 27.3598 2.69884 29.826C1.86829 30.7002 1.0349 31.5745 0.36364 32.5L10.9474 23.0341"/></svg>',
|
|
628
|
+
// Factory publishes no mark in the icon set, so droid keeps an honest generic
|
|
629
|
+
// machine glyph rather than an invented "official" logo.
|
|
630
|
+
// Factory's own mark, taken from factory.ai's nav logo.
|
|
631
|
+
droid: '<svg viewBox="0 0 67 65" width="15" height="15" aria-hidden="true">' +
|
|
632
|
+
'<path fill="currentColor" d="M47.75 11.15a.867.867 0 0 1-.671-.806.84.84 0 0 1 .067-.362c1.688-4.007 2.433-7.213 1.23-8.555-3.183-3.56-15.952 3.52-20.024 5.919a.9.9 0 0 1-1.273-.41c-1.711-3.998-3.51-6.78-5.334-6.9-4.833-.323-8.73 13.49-9.87 17.992a.85.85 0 0 1-.459.563.9.9 0 0 1-.737.027c-4.109-1.647-7.398-2.373-8.773-1.2-3.651 3.104 3.609 15.557 6.068 19.528a.85.85 0 0 1-.11 1.031.9.9 0 0 1-.31.21C3.455 39.856.604 41.61.478 43.389c-.329 4.713 13.834 8.513 18.452 9.625q.186.046.337.163a.87.87 0 0 1 .332.642.84.84 0 0 1-.067.362c-1.688 4.007-2.433 7.214-1.23 8.555 3.183 3.561 15.954-3.519 20.025-5.917a.9.9 0 0 1 1.058.107.9.9 0 0 1 .215.302c1.711 3.997 3.509 6.779 5.334 6.9 4.833.322 8.73-13.49 9.868-17.993a.85.85 0 0 1 .168-.33.88.88 0 0 1 .659-.324.9.9 0 0 1 .371.066c4.109 1.647 7.397 2.372 8.773 1.2 3.651-3.105-3.61-15.559-6.07-19.53a.85.85 0 0 1 .111-1.03.9.9 0 0 1 .31-.21c4.1-1.67 6.952-3.424 7.075-5.203.331-4.713-13.833-8.513-18.45-9.623m-5.546-4.518c.93 1.624-3.858 12.446-7.42 20.015a.7.7 0 0 1-.28.303.71.71 0 0 1-.796-.059.7.7 0 0 1-.23-.341c-1.439-4.921-3.082-10.704-4.841-15.612a.84.84 0 0 1 .01-.594.87.87 0 0 1 .401-.446c4.392-2.34 11.908-5.446 13.156-3.266m-21.048 1.34c1.833.507 6.294 11.46 9.264 19.268a.67.67 0 0 1-.2.754.71.71 0 0 1-.794.08c-4.589-2.485-9.94-5.444-14.743-7.702a.87.87 0 0 1-.422-.427.84.84 0 0 1-.04-.591c1.414-4.679 4.471-12.063 6.935-11.383M7.243 23.433c1.664-.906 12.762 3.763 20.522 7.235.13.058.239.154.311.274a.67.67 0 0 1-.06.776.7.7 0 0 1-.35.225c-5.045 1.403-10.976 3.006-16.01 4.721a.9.9 0 0 1-.607-.01.88.88 0 0 1-.456-.391c-2.395-4.284-5.586-11.613-3.35-12.83M8.617 43.96c.519-1.788 11.752-6.14 19.758-9.035a.72.72 0 0 1 .773.195.67.67 0 0 1 .081.774c-2.548 4.475-5.582 9.694-7.898 14.377a.87.87 0 0 1-.437.413.9.9 0 0 1-.607.039c-4.797-1.37-12.37-4.36-11.67-6.763m15.855 13.568c-.93-1.623 3.859-12.446 7.42-20.014a.7.7 0 0 1 .28-.303.715.715 0 0 1 .796.059.7.7 0 0 1 .23.34c1.439 4.92 3.083 10.705 4.841 15.613a.84.84 0 0 1-.01.593.87.87 0 0 1-.402.445c-4.391 2.335-11.908 5.447-13.15 3.267zm21.049-1.34c-1.836-.506-6.297-11.461-9.266-19.269a.67.67 0 0 1 .2-.755.71.71 0 0 1 .795-.078c4.587 2.484 9.94 5.445 14.742 7.703.189.088.339.24.423.426a.84.84 0 0 1 .039.592c-1.413 4.686-4.47 12.063-6.933 11.381m13.912-15.462c-1.665.907-12.762-3.763-20.523-7.236a.7.7 0 0 1-.311-.273.67.67 0 0 1 .06-.777.7.7 0 0 1 .35-.225c5.046-1.402 10.975-3.005 16.009-4.72a.9.9 0 0 1 .609.01.88.88 0 0 1 .457.392c2.393 4.282 5.584 11.613 3.349 12.829M58.06 20.2c-.521 1.79-11.753 6.14-19.759 9.036a.72.72 0 0 1-.774-.195.67.67 0 0 1-.08-.776c2.547-4.474 5.581-9.694 7.897-14.377a.87.87 0 0 1 .437-.412.9.9 0 0 1 .607-.038c4.797 1.377 12.37 4.359 11.672 6.762"/></svg>',
|
|
633
|
+
};
|
|
634
|
+
/**
|
|
635
|
+
* A mark for an agent with no official one.
|
|
636
|
+
*
|
|
637
|
+
* A single dot for every unknown agent made three reviewers look like the same
|
|
638
|
+
* anonymous thing, and unbranded next to codex's real mark. These are
|
|
639
|
+
* deliberately geometric rather than logo-like: inventing an "official" mark
|
|
640
|
+
* for someone else's product would be worse than having none. The shape is
|
|
641
|
+
* picked by hashing the name, so an agent keeps its glyph across reloads and
|
|
642
|
+
* two agents in the same run are distinguishable at a glance.
|
|
643
|
+
*/
|
|
644
|
+
const GLYPHS = [
|
|
645
|
+
// hexagon
|
|
646
|
+
'<path fill="currentColor" d="M12 2.6 20.1 7v10L12 21.4 3.9 17V7z" opacity=".9"/>',
|
|
647
|
+
// rounded square, hollow centre
|
|
648
|
+
'<path fill="currentColor" d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 3v12h12V6Z"/><circle cx="12" cy="12" r="2.6" fill="currentColor"/>',
|
|
649
|
+
// triangle
|
|
650
|
+
'<path fill="currentColor" d="M12 3.2 21.5 20H2.5z"/>',
|
|
651
|
+
// four-point star
|
|
652
|
+
'<path fill="currentColor" d="M12 2c.6 5 2.4 7.4 10 10-7.6 2.6-9.4 5-10 10-.6-5-2.4-7.4-10-10 7.6-2.6 9.4-5 10-10Z"/>',
|
|
653
|
+
// concentric rings
|
|
654
|
+
'<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="12" cy="12" r="3.4" fill="currentColor"/>',
|
|
655
|
+
// diamond
|
|
656
|
+
'<path fill="currentColor" d="M12 2.2 21.8 12 12 21.8 2.2 12z"/>',
|
|
657
|
+
// three bars
|
|
658
|
+
'<path fill="currentColor" d="M3 5h18v3.4H3zM3 10.3h18v3.4H3zM3 15.6h18V19H3z"/>',
|
|
659
|
+
// pentagon
|
|
660
|
+
'<path fill="currentColor" d="M12 2.4 22 9.7l-3.8 11.7H5.8L2 9.7z"/>',
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
const icon = (name) => {
|
|
664
|
+
if (ICONS[name]) return ICONS[name];
|
|
665
|
+
const n = String(name ?? "");
|
|
666
|
+
let h = 0;
|
|
667
|
+
for (let i = 0; i < n.length; i++) h = (h * 31 + n.charCodeAt(i)) | 0;
|
|
668
|
+
const g = GLYPHS[Math.abs(h) % GLYPHS.length];
|
|
669
|
+
return `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">${g}</svg>`;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// Times are pinned to Singapore, not the viewer's locale: a review record should
|
|
673
|
+
// read the same wherever it is opened, and 24-hour reads better against log data.
|
|
674
|
+
const TZ = "Asia/Singapore";
|
|
675
|
+
const TZ_LABEL = "SGT";
|
|
676
|
+
const clockFmt = new Intl.DateTimeFormat("en-GB", {
|
|
677
|
+
timeZone: TZ, hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// A turn carries an ISO time when it was measured; the round/offset label is
|
|
681
|
+
// secondary context, shown under it rather than wrapped into the same line.
|
|
682
|
+
function when(t) {
|
|
683
|
+
let clock = "";
|
|
684
|
+
if (t?.at) {
|
|
685
|
+
const d = new Date(t.at);
|
|
686
|
+
if (!Number.isNaN(+d)) clock = clockFmt.format(d);
|
|
687
|
+
}
|
|
688
|
+
const ctx = t?.ts ?? "";
|
|
689
|
+
return `${clock ? `<b>${esc(clock)}</b>` : ""}${ctx ? `<i>${esc(ctx)}</i>` : ""}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const $ = id => document.getElementById(id);
|
|
693
|
+
const esc = s => String(s).replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c]));
|
|
694
|
+
const rich = s => esc(s).replace(/<strong>/g,"<strong>").replace(/<\/strong>/g,"</strong>")
|
|
695
|
+
.replace(/<b>/g,"<b>").replace(/<\/b>/g,"</b>");
|
|
696
|
+
const pct = m => (m / RUN.totalMin) * 100;
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Merge timeline marks too close together to render side by side.
|
|
700
|
+
*
|
|
701
|
+
* A label is roughly 8% of the track at the widths this is read at, so marks
|
|
702
|
+
* nearer than that collide. Rather than dropping the later one — which would
|
|
703
|
+
* misreport how many rounds ran — they collapse into a range: "rounds 3-6",
|
|
704
|
+
* with the full list on hover.
|
|
705
|
+
*/
|
|
706
|
+
function collapseMarks(marks = []) {
|
|
707
|
+
const sorted = [...marks].sort((a, b) => a.at - b.at);
|
|
708
|
+
const out = [];
|
|
709
|
+
const MIN_GAP = 8; // percent of the track
|
|
710
|
+
for (const m of sorted) {
|
|
711
|
+
const last = out[out.length - 1];
|
|
712
|
+
if (last && pct(m.at) - pct(last.at) < MIN_GAP) {
|
|
713
|
+
last.group.push(m.l);
|
|
714
|
+
const ns = last.group.map((l) => String(l).replace(/\D+/g, "")).filter(Boolean);
|
|
715
|
+
last.l = ns.length > 1 ? `rounds ${ns[0]}-${ns[ns.length - 1]}` : last.group[0];
|
|
716
|
+
last.title = last.group.join(", ");
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
out.push({ at: m.at, l: m.l, title: m.l, group: [m.l] });
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// exchanges. "open" is a finding the main agent has not answered yet, and it is
|
|
725
|
+
// the state a live run spends most of its time in — leaving it out of the
|
|
726
|
+
// filters hid every finding the CLI recorded.
|
|
727
|
+
|
|
728
|
+
const STATES = ["open","accepted","deferred","rejected"];
|
|
729
|
+
let show = new Set(STATES);
|
|
730
|
+
$("filters").innerHTML = STATES
|
|
731
|
+
.map(v=>`<button class="chip" data-v="${v}" aria-pressed="true">${v}</button>`).join("");
|
|
732
|
+
|
|
733
|
+
function draw(){
|
|
734
|
+
const all = RUN.exchanges ?? [];
|
|
735
|
+
const rows = all.filter(x => show.has(x.res ?? x.resolution));
|
|
736
|
+
const t = RUN.target ?? {};
|
|
737
|
+
const cmd = t.url ? `jury review ${t.url}` : `jury review --dir . --trunk ${t.trunk ?? "master"}`;
|
|
738
|
+
|
|
739
|
+
// This copy has to describe THIS run. A line about the rejected finding was
|
|
740
|
+
// being printed over a run that raised no findings at all.
|
|
741
|
+
const verdicts = (RUN.rounds ?? []).flatMap(r => r.verdicts ?? []);
|
|
742
|
+
const allClean = verdicts.length > 0 && verdicts.every(v => v.s === "clean");
|
|
743
|
+
$("x-note").textContent = RUN.xnote ?? (all.length
|
|
744
|
+
? "Every finding gets a reply, including the ones the main agent rejects — with the evidence that settled them."
|
|
745
|
+
: allClean
|
|
746
|
+
? "Nothing to answer: every reviewer read the diff and reported no new problems."
|
|
747
|
+
: "Nothing tracked yet for this run.");
|
|
748
|
+
|
|
749
|
+
const empty = all.length ? "no exchanges match this filter"
|
|
750
|
+
: allClean ? "no findings raised — every reviewer reported clean"
|
|
751
|
+
: "no findings recorded yet";
|
|
752
|
+
|
|
753
|
+
const card = x => `
|
|
754
|
+
<div class="xb">
|
|
755
|
+
<div class="xb-head">
|
|
756
|
+
<span class="claim">${esc(x.claim)}</span>
|
|
757
|
+
<span class="tag" data-r="${x.res ?? x.resolution}">${x.res ?? x.resolution}</span>
|
|
758
|
+
<span class="meta">${esc(x.id ?? "")} · ${esc(x.loc ?? "")}</span>
|
|
759
|
+
</div>
|
|
760
|
+
${(x.turns ?? []).map(tt => `
|
|
761
|
+
<div class="ln" data-who="${tt.who}">
|
|
762
|
+
<span class="at">${when(tt)}</span>
|
|
763
|
+
<span class="nm">${icon(tt.who)}${esc(tt.who)}</span>
|
|
764
|
+
<span class="say">${rich(tt.body)}
|
|
765
|
+
${tt.quote ? `<div class="q">${esc(tt.quote)}</div>` : ""}
|
|
766
|
+
${tt.ask ? `<div class="ask">${esc(tt.ask)}</div>` : ""}
|
|
767
|
+
</span>
|
|
768
|
+
</div>`).join("")}
|
|
769
|
+
${x.outcome ? `<div class="out" data-r="${x.res ?? x.resolution}"><b>${x.res ?? x.resolution}</b> — ${esc(x.outcome)}</div>` : ""}
|
|
770
|
+
</div>`;
|
|
771
|
+
|
|
772
|
+
// Grouped by reviewer, in the order they first spoke. A single flat list
|
|
773
|
+
// reads as one debate among everyone; these were N separate conversations,
|
|
774
|
+
// and which reviewer raised a claim is most of what makes it worth weighing.
|
|
775
|
+
// An unattributed finding still has to appear somewhere, or grouping would
|
|
776
|
+
// silently drop it — a finding nobody can see is worse than an ugly heading.
|
|
777
|
+
const who = [];
|
|
778
|
+
for (const x of all) {
|
|
779
|
+
const k = x.with || "unattributed";
|
|
780
|
+
if (!who.includes(k)) who.push(k);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const body = !rows.length
|
|
784
|
+
? `<div style="color:var(--t-dim);padding:8px 0">${esc(empty)}</div>`
|
|
785
|
+
// Follows the conversation picked above, so the findings on screen are the
|
|
786
|
+
// ones raised in the thread you are reading. Stacked means "show me all of
|
|
787
|
+
// them", and with only one reviewer there is nothing to follow.
|
|
788
|
+
// Derived, not read off a global: draw() runs before drawConvo() on the
|
|
789
|
+
// first paint, so consulting CONVO_PICK directly showed every reviewer once
|
|
790
|
+
// and then silently disagreed with the tab that ended up pressed.
|
|
791
|
+
: who.filter(a => who.length < 2 || CONVO_STACK || a === (who.includes(CONVO_PICK) ? CONVO_PICK : who[0])).map(agent => {
|
|
792
|
+
const is = x => (x.with || "unattributed") === agent;
|
|
793
|
+
const mine = rows.filter(is);
|
|
794
|
+
if (!mine.length) return "";
|
|
795
|
+
const shown = all.filter(is).length;
|
|
796
|
+
return `
|
|
797
|
+
<div class="xgroup">
|
|
798
|
+
<div class="rule" data-who="${esc(agent)}">
|
|
799
|
+
<span class="lbl">${icon(agent)}claude ⇄ ${esc(agent)}</span><i></i>
|
|
800
|
+
<span class="rt">${mine.length} of ${shown} finding(s)</span>
|
|
801
|
+
</div>
|
|
802
|
+
${mine.map(card).join("")}
|
|
803
|
+
</div>`;
|
|
804
|
+
}).join("") || `<div style="color:var(--t-dim);padding:8px 0">${esc(empty)}</div>`;
|
|
805
|
+
|
|
806
|
+
$("thread").innerHTML = `
|
|
807
|
+
<div class="term">
|
|
808
|
+
<div class="term-bar">
|
|
809
|
+
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
|
810
|
+
<span class="t">${esc(t.repo ?? "")} ${esc(t.id ?? "")} — exchange</span>
|
|
811
|
+
</div>
|
|
812
|
+
<div class="term-body">
|
|
813
|
+
<div class="cmdline"><span class="cwd">~/${esc((t.repo ?? "repo").split("/").pop())}</span> <span class="branch">git:(${esc(t.branch ?? "?")})</span></div>
|
|
814
|
+
<div class="cmdline"><span class="p">❯</span><span class="c">${esc(cmd)}</span></div>
|
|
815
|
+
${body}
|
|
816
|
+
</div>
|
|
817
|
+
</div>`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
$("filters").addEventListener("click", e=>{
|
|
821
|
+
const b=e.target.closest(".chip"); if(!b) return;
|
|
822
|
+
const v=b.dataset.v;
|
|
823
|
+
if(show.has(v)){ show.delete(v); b.setAttribute("aria-pressed","false"); }
|
|
824
|
+
else { show.add(v); b.setAttribute("aria-pressed","true"); }
|
|
825
|
+
draw();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const BADGE = { merged:"badge-ok", converged:"badge-ok", review:"badge-warn", human:"badge-warn", queued:"badge-mute" };
|
|
829
|
+
|
|
830
|
+
function renderHeader(){
|
|
831
|
+
const t = RUN.target ?? {};
|
|
832
|
+
// Name the repo here, next to the switcher, rather than only in the meta row
|
|
833
|
+
// below the title: the chips read "#46 / #48" and a bare number does not say
|
|
834
|
+
// what project it belongs to.
|
|
835
|
+
const scope = [...new Set(ALL.map(r => r.target?.repo).filter(Boolean))];
|
|
836
|
+
$("t-kicker").textContent = ALL.length > 1
|
|
837
|
+
? `${scope.length === 1 ? scope[0] : `${scope.length} repos`} · ${ALL.length} pull requests`
|
|
838
|
+
: `${t.repo ? `${t.repo} · ` : ""}single pull request`;
|
|
839
|
+
$("t-title").textContent = t.title ?? "(untitled change)";
|
|
840
|
+
$("t-summary").textContent = t.summary ?? "";
|
|
841
|
+
|
|
842
|
+
const link = t.url
|
|
843
|
+
? `<a href="${esc(t.url)}" target="_blank" rel="noopener">${esc(t.repo ?? "")} ${esc(t.id ?? "")}</a>`
|
|
844
|
+
: `<span>${esc(t.repo ?? "")} ${esc(t.id ?? "")}</span>`;
|
|
845
|
+
const branch = t.branch ? `<span>${esc(t.branch)} → ${esc(t.trunk ?? "master")}</span>` : "";
|
|
846
|
+
const stat = t.diffstat ? `<span>${esc(t.diffstat)}</span>` : "";
|
|
847
|
+
const cls = BADGE[t.state] ?? "badge-mute";
|
|
848
|
+
const badge = t.state
|
|
849
|
+
? `<span class="${cls}"><span class="d"></span>${esc(t.stateNote || t.state)}</span>` : "";
|
|
850
|
+
$("t-meta").innerHTML = [link, branch, stat, badge].filter(Boolean).join("");
|
|
851
|
+
|
|
852
|
+
// Only worth showing when there is something to switch between.
|
|
853
|
+
//
|
|
854
|
+
// A bare "#48" does not say which project it belongs to, and PR numbers
|
|
855
|
+
// collide across repos — two chips reading #48 would be indistinguishable.
|
|
856
|
+
// The repo name is carried whenever more than one is on screen; with a single
|
|
857
|
+
// repo it is redundant, and the title is what actually tells them apart.
|
|
858
|
+
const repos = new Set(ALL.map(r => r.target?.repo).filter(Boolean));
|
|
859
|
+
drawSwitcher();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/* ── Run switcher ───────────────────────────────────────────────────────
|
|
863
|
+
A chip per run wrapped to two rows at seven runs; at a hundred it is a
|
|
864
|
+
wall. One control names the current run, and opening it gives a filterable
|
|
865
|
+
list with whatever is LIVE first — a running review is the thing you came
|
|
866
|
+
to look at, and it must never be somewhere down a list of two hundred. */
|
|
867
|
+
let SW_OPEN = false;
|
|
868
|
+
let SW_Q = "";
|
|
869
|
+
|
|
870
|
+
// "Live" cannot be the state field alone. A run that was killed keeps whatever
|
|
871
|
+
// state it was last written with, so six abandoned runs all claimed to be
|
|
872
|
+
// under review — and the one actually running was lost among them. A run is
|
|
873
|
+
// live if it says it is reviewing AND something happened in the last few
|
|
874
|
+
// minutes.
|
|
875
|
+
const LIVE_WINDOW_MS = 6 * 60 * 1000;
|
|
876
|
+
function lastActivity(r) {
|
|
877
|
+
// A verdict is the best signal, but a run whose reviewer never reported has
|
|
878
|
+
// none — and those are exactly the runs you just started and want at the top.
|
|
879
|
+
// Fall back to the newest round, so nothing sorts as "no activity ever".
|
|
880
|
+
const verdict = (r.rounds ?? []).flatMap((x) => x.verdicts ?? []).map((v) => v.at).filter(Boolean).pop();
|
|
881
|
+
if (verdict) return Date.parse(verdict);
|
|
882
|
+
const started = (r.rounds ?? []).map((x) => x.startedAt).filter(Boolean).pop();
|
|
883
|
+
return started ? Date.parse(started) : null;
|
|
884
|
+
}
|
|
885
|
+
function isLive(r) {
|
|
886
|
+
if (r.target?.state !== "review") return false;
|
|
887
|
+
// A launched round with no verdict yet is live by definition: the reviewer
|
|
888
|
+
// has not reported, which is exactly the case worth watching.
|
|
889
|
+
if (r === RUN && EVENTS.length) {
|
|
890
|
+
const last = EVENTS[EVENTS.length - 1];
|
|
891
|
+
if (last?.ts && Date.now() - Date.parse(last.ts) < LIVE_WINDOW_MS) return true;
|
|
892
|
+
}
|
|
893
|
+
const at = lastActivity(r);
|
|
894
|
+
return at !== null && Date.now() - at < LIVE_WINDOW_MS;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function runLabel(r) {
|
|
898
|
+
const t = r.target ?? {};
|
|
899
|
+
const repo = (t.repo ?? "").split("/").pop() || "run";
|
|
900
|
+
return `${repo} · ${t.id ?? "?"}`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Which attempt this is, when the same PR has been reviewed more than once.
|
|
905
|
+
*
|
|
906
|
+
* Every run of PR #2 is labelled "#2", so without this three separate reviews
|
|
907
|
+
* are three identical rows. Shown only when there IS more than one — a lone run
|
|
908
|
+
* does not need to defend its uniqueness.
|
|
909
|
+
*/
|
|
910
|
+
function attemptOf(r) {
|
|
911
|
+
const m = /-(\d{8})-(\d{4})$/.exec(r.slug ?? "");
|
|
912
|
+
if (!m) return "";
|
|
913
|
+
const [, d, hm] = m;
|
|
914
|
+
return `${d.slice(4, 6)}/${d.slice(6, 8)} ${hm.slice(0, 2)}:${hm.slice(2)}`;
|
|
915
|
+
}
|
|
916
|
+
function hasSiblings(r, all) {
|
|
917
|
+
const key = `${r.target?.repo}#${r.target?.id}`;
|
|
918
|
+
return all.filter((x) => `${x.target?.repo}#${x.target?.id}` === key).length > 1;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// "16h", "3d" — a run's age is glanceable, an ISO timestamp is not.
|
|
922
|
+
function ago(r) {
|
|
923
|
+
const last = lastActivity(r);
|
|
924
|
+
if (!last) return "";
|
|
925
|
+
const s = (Date.now() - last) / 1000;
|
|
926
|
+
if (!Number.isFinite(s) || s < 0) return "";
|
|
927
|
+
if (s < 90) return "just now";
|
|
928
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
929
|
+
if (s < 172800) return `${Math.round(s / 3600)}h`;
|
|
930
|
+
return `${Math.round(s / 86400)}d`;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function runSummary(r) {
|
|
934
|
+
const t = r.target ?? {};
|
|
935
|
+
if (isLive(r) && t.stateNote) return t.stateNote;
|
|
936
|
+
const n = (r.exchanges ?? []).length;
|
|
937
|
+
if (t.state === "converged") return "converged";
|
|
938
|
+
const found = n ? `${n} finding${n === 1 ? "" : "s"}` : "";
|
|
939
|
+
// A stale "review" is a run that stopped without finishing. Saying "review"
|
|
940
|
+
// would repeat the claim that misled the grouping in the first place.
|
|
941
|
+
if (t.state === "review") return found ? `${found} · stopped` : "stopped";
|
|
942
|
+
return found || t.stateNote || t.state || "";
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function drawSwitcher() {
|
|
946
|
+
const el = $("switcher");
|
|
947
|
+
if (!el) return;
|
|
948
|
+
if (ALL.length < 2) { el.innerHTML = ""; return; }
|
|
949
|
+
|
|
950
|
+
const q = SW_Q.trim().toLowerCase();
|
|
951
|
+
const match = (r) => {
|
|
952
|
+
if (!q) return true;
|
|
953
|
+
const t = r.target ?? {};
|
|
954
|
+
return [t.repo, t.id, t.title, t.branch, r.slug]
|
|
955
|
+
.filter(Boolean).join(" ").toLowerCase().includes(q);
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const hits = ALL.filter(match);
|
|
959
|
+
// Newest first, full stop. Grouping by repo read well in the abstract and
|
|
960
|
+
// badly in practice: it floated 9-day-old runs above ones from ten minutes
|
|
961
|
+
// ago, so the list did not answer "what was I just looking at?" — which is
|
|
962
|
+
// the only question this control gets asked.
|
|
963
|
+
hits.sort((a, b) => (lastActivity(b) ?? 0) - (lastActivity(a) ?? 0));
|
|
964
|
+
const live = hits.filter(isLive);
|
|
965
|
+
const rest = hits.filter((r) => !isLive(r));
|
|
966
|
+
|
|
967
|
+
const item = (r) => {
|
|
968
|
+
const t = r.target ?? {};
|
|
969
|
+
const on = r === RUN;
|
|
970
|
+
return `<button class="sw-i" role="option" data-slug="${esc(r.slug ?? "")}" aria-pressed="${on}"
|
|
971
|
+
><span class="d" style="background:${isLive(r) ? "var(--warn)" : t.state === "converged" ? "var(--ok)" : "var(--muted)"}"></span
|
|
972
|
+
><span class="id">${esc(runLabel(r))}</span
|
|
973
|
+
>${hasSiblings(r, ALL) && attemptOf(r) ? `<span class="at">${esc(attemptOf(r))}</span>` : ""
|
|
974
|
+
}<span class="ti">${esc(runSummary(r))}</span
|
|
975
|
+
><span class="rt">${esc(ago(r))}</span></button>`;
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const group = (label, rows) =>
|
|
979
|
+
rows.length ? `<div class="sw-h">${label}</div>${rows.map(item).join("")}` : "";
|
|
980
|
+
|
|
981
|
+
el.innerHTML = `
|
|
982
|
+
<button class="sw-btn" id="sw-btn" aria-expanded="${SW_OPEN}"
|
|
983
|
+
><span class="d" style="width:7px;height:7px;border-radius:50%;background:${
|
|
984
|
+
isLive(RUN) ? "var(--warn)" : "var(--ok)"};flex:none"></span
|
|
985
|
+
><span class="nm">${esc(runLabel(RUN))}${
|
|
986
|
+
hasSiblings(RUN, ALL) && attemptOf(RUN) ? ` · ${attemptOf(RUN)}` : ""}</span
|
|
987
|
+
><span class="cnt">of ${ALL.length}</span><span class="car">▼</span></button>
|
|
988
|
+
<div class="sw-pop" id="sw-pop" ${SW_OPEN ? "" : "hidden"} role="listbox">
|
|
989
|
+
<input id="sw-q" type="search" placeholder="filter by repo, number, title, branch…"
|
|
990
|
+
value="${esc(SW_Q)}" autocomplete="off">
|
|
991
|
+
<div class="sw-list">${
|
|
992
|
+
hits.length
|
|
993
|
+
? group(live.length ? `running now · ${live.length}` : "", live)
|
|
994
|
+
+ group(`recent · ${rest.length}`, rest)
|
|
995
|
+
: `<div class="sw-none">nothing matches “${esc(SW_Q)}”</div>`
|
|
996
|
+
}</div>
|
|
997
|
+
</div>`;
|
|
998
|
+
|
|
999
|
+
if (SW_OPEN) {
|
|
1000
|
+
const input = $("sw-q");
|
|
1001
|
+
input?.focus();
|
|
1002
|
+
input?.setSelectionRange(SW_Q.length, SW_Q.length);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
document.addEventListener("click", (e) => {
|
|
1007
|
+
if (e.target.closest("#sw-btn")) { SW_OPEN = !SW_OPEN; SW_Q = ""; drawSwitcher(); return; }
|
|
1008
|
+
const pick = e.target.closest("#sw-pop .sw-i");
|
|
1009
|
+
if (pick) {
|
|
1010
|
+
const next = ALL.find((r) => r.slug === pick.dataset.slug);
|
|
1011
|
+
if (next) { RUN = next; setRunParam(next.slug); SW_OPEN = false; SW_Q = ""; render(); }
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
// Anywhere else closes it — including the page behind.
|
|
1015
|
+
if (SW_OPEN && !e.target.closest("#sw-pop")) { SW_OPEN = false; drawSwitcher(); }
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
document.addEventListener("input", (e) => {
|
|
1019
|
+
if (e.target.id !== "sw-q") return;
|
|
1020
|
+
SW_Q = e.target.value;
|
|
1021
|
+
drawSwitcher();
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
document.addEventListener("keydown", (e) => {
|
|
1025
|
+
if (!SW_OPEN) return;
|
|
1026
|
+
if (e.key === "Escape") { SW_OPEN = false; drawSwitcher(); return; }
|
|
1027
|
+
if (e.key !== "Enter") return;
|
|
1028
|
+
// Enter takes the first match, so filtering to one run and pressing Enter is
|
|
1029
|
+
// the whole interaction.
|
|
1030
|
+
const first = document.querySelector("#sw-pop .sw-i");
|
|
1031
|
+
if (first) { e.preventDefault(); first.click(); }
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Every reviewer's report in full, plus the prompt it answered. A round where
|
|
1035
|
+
// nobody found anything still has to show what was checked — the first line
|
|
1036
|
+
// alone ("NO NEW FINDINGS") hid caveats like a reviewer that could not run the
|
|
1037
|
+
// tests at all.
|
|
1038
|
+
const bytes = n => n < 1024 ? `${n} B` : `${(n/1024).toFixed(1)} kB`;
|
|
1039
|
+
|
|
1040
|
+
// report ⇄ raw stream, now hung off the conversation rather than the removed
|
|
1041
|
+
// round-by-round transcript. Fetched on demand: a verbose agent's stream can
|
|
1042
|
+
// dwarf the rest of the payload, and most of it is transcript rather than
|
|
1043
|
+
// answer.
|
|
1044
|
+
document.addEventListener("click", async (e) => {
|
|
1045
|
+
const b = e.target.closest("#convo-grid button[data-view]");
|
|
1046
|
+
if (!b) return;
|
|
1047
|
+
const tabs = b.closest(".tabs");
|
|
1048
|
+
const pre = tabs.previousElementSibling;
|
|
1049
|
+
const want = b.dataset.view;
|
|
1050
|
+
if (b.getAttribute("aria-pressed") === "true") return;
|
|
1051
|
+
|
|
1052
|
+
if (want === "raw") {
|
|
1053
|
+
if (!pre.dataset.report) pre.dataset.report = pre.textContent;
|
|
1054
|
+
if (!pre.dataset.raw) {
|
|
1055
|
+
b.textContent = "loading…";
|
|
1056
|
+
try {
|
|
1057
|
+
const res = await fetch(`api/raw?run=${encodeURIComponent(b.dataset.slug)}&file=${encodeURIComponent(b.dataset.for)}`);
|
|
1058
|
+
pre.dataset.raw = res.ok ? await res.text() : `(${res.status} — raw stream not on disk for this round)`;
|
|
1059
|
+
} catch { pre.dataset.raw = "(raw stream unavailable — is the console still serving?)"; }
|
|
1060
|
+
b.textContent = "raw stream";
|
|
1061
|
+
}
|
|
1062
|
+
pre.textContent = pre.dataset.raw;
|
|
1063
|
+
pre.classList.add("raw");
|
|
1064
|
+
} else {
|
|
1065
|
+
pre.textContent = pre.dataset.report ?? pre.textContent;
|
|
1066
|
+
pre.classList.remove("raw");
|
|
1067
|
+
}
|
|
1068
|
+
for (const other of tabs.querySelectorAll("button[data-view]")) {
|
|
1069
|
+
other.setAttribute("aria-pressed", String(other === b));
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
function render(){
|
|
1074
|
+
renderHeader();
|
|
1075
|
+
// timeline
|
|
1076
|
+
$("tl").innerHTML =
|
|
1077
|
+
// Marks that land within a label's width of each other are drawn as one.
|
|
1078
|
+
// Absolute positioning with no collision handling rendered six rounds a
|
|
1079
|
+
// few seconds apart as "ROUNDROUNDROUND23" — unreadable, and it hid how
|
|
1080
|
+
// many rounds there actually were.
|
|
1081
|
+
`<div class="tl-rounds">${collapseMarks(RUN.marks).map(m=>
|
|
1082
|
+
`<span style="left:${pct(m.at)}%" title="${esc(m.title ?? m.l)}">${esc(m.l)}</span>`).join("")}</div>` +
|
|
1083
|
+
RUN.lanes.map(l=>`
|
|
1084
|
+
<div class="lane">
|
|
1085
|
+
<span class="who"><span class="ic" style="color:${l.who==="main"?"var(--main-hue)":hueFor(l.who)}">${icon(l.label)}</span>${esc(l.label)}</span>
|
|
1086
|
+
<div class="track">${l.segs.map(s=>{
|
|
1087
|
+
// Rounded at the point of display: these are differences of floats,
|
|
1088
|
+
// so a span reads "8.300000000000004 min" without it.
|
|
1089
|
+
const w = pct(s.e - s.s), mins = +(s.e - s.s).toFixed(1);
|
|
1090
|
+
return `<div class="seg" data-a="${l.who}"${s.open ? ' data-open="1"' : ""}
|
|
1091
|
+
style="left:${pct(s.s)}%;width:${w}%"${s.abandoned ? ' data-abandoned="1"' : ""}
|
|
1092
|
+
title="${esc(s.t || ("round "+s.r+" · "+mins+" min"))}">${w > 9 ? esc(s.t || mins+" min") : ""}</div>`;
|
|
1093
|
+
}).join("")}</div>
|
|
1094
|
+
</div>`).join("");
|
|
1095
|
+
$("tlfoot").textContent = RUN.tlfoot;
|
|
1096
|
+
|
|
1097
|
+
$("rounds").innerHTML = RUN.rounds.map(r=>`
|
|
1098
|
+
<article class="rcard">
|
|
1099
|
+
<header><b>Round ${r.n}</b><span class="sha">${esc(r.sha)}</span></header>
|
|
1100
|
+
${r.verdicts.map(v=>`
|
|
1101
|
+
<div class="verd" data-s="${v.s}">
|
|
1102
|
+
<span class="ag"><span class="ic" style="color:${hueFor(v.a)}">${icon(v.a)}</span>${esc(v.a)}</span>
|
|
1103
|
+
<span class="txt">${rich(v.t)}</span>
|
|
1104
|
+
</div>`).join("")}
|
|
1105
|
+
</article>`).join("");
|
|
1106
|
+
|
|
1107
|
+
// A run that has not shipped anything yet has no tally. An empty grid under a
|
|
1108
|
+
// heading reads as a rendering failure, so drop the whole section.
|
|
1109
|
+
const ship = RUN.ship ?? [];
|
|
1110
|
+
$("ship-sec").style.display = ship.length ? "" : "none";
|
|
1111
|
+
$("ship").innerHTML = ship.map(s=>`
|
|
1112
|
+
<div><span class="n">${esc(s.n)}</span><span class="k">${esc(s.k)}</span><span class="s">${esc(s.s)}</span></div>`).join("");
|
|
1113
|
+
// Same reason as the ship grid: an empty footer still draws its rule, which
|
|
1114
|
+
// reads as content that failed to load.
|
|
1115
|
+
$("foot").textContent = RUN.foot ?? "";
|
|
1116
|
+
$("foot").style.display = RUN.foot ? "" : "none";
|
|
1117
|
+
draw();
|
|
1118
|
+
// The stream follows whichever run is on screen; switching PRs switches it.
|
|
1119
|
+
connect(RUN.slug ?? null);
|
|
1120
|
+
drawConvo();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/* ── Conversation ─────────────────────────────────────────────────────
|
|
1124
|
+
Rendered from the event stream, not from run.json. The timeline needs a
|
|
1125
|
+
folded run; a conversation needs the events in the order they were spoken,
|
|
1126
|
+
and folding throws that order away.
|
|
1127
|
+
|
|
1128
|
+
Side is fixed by speaker, never by turn order: claude left, reviewer right.
|
|
1129
|
+
A reader should be able to tell who is talking from across the room. */
|
|
1130
|
+
let THREADS = []; // [{agent, turns:[…]}]
|
|
1131
|
+
let CONVO_STACK = false; // one thread full width, or every thread stacked
|
|
1132
|
+
let CONVO_PICK = null; // which thread, when not stacked
|
|
1133
|
+
const CONVO_SHUT = new Set(); // collapsed threads, by agent, while stacked
|
|
1134
|
+
|
|
1135
|
+
const SIDE = (who) => (who === "claude" ? "l" : "r");
|
|
1136
|
+
|
|
1137
|
+
// A stable colour per agent. codex and droid have tokens already; anything else
|
|
1138
|
+
// is hashed to a hue so a five-reviewer run does not render five identical
|
|
1139
|
+
// chips — and so the same agent keeps its colour across reloads.
|
|
1140
|
+
const HUES = {
|
|
1141
|
+
claude: "var(--main-hue)", codex: "var(--codex)", droid: "var(--droid)",
|
|
1142
|
+
grok: "var(--ink-2)", agy: "var(--agy)",
|
|
1143
|
+
};
|
|
1144
|
+
function hueFor(name) {
|
|
1145
|
+
if (HUES[name]) return HUES[name];
|
|
1146
|
+
let h = 0;
|
|
1147
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
|
|
1148
|
+
return `hsl(${Math.abs(h) % 360} 42% 45%)`;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const VERDICT_TEXT = {
|
|
1152
|
+
accepted: "Accepted — fixed",
|
|
1153
|
+
deferred: "Agreed, but not fixing here",
|
|
1154
|
+
rejected: "Rejected",
|
|
1155
|
+
superseded: "Superseded",
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
// Long reports collapse. A twenty-minute reviewer turn can be thousands of
|
|
1159
|
+
// lines, and an un-collapsed one buries every exchange after it.
|
|
1160
|
+
const CLAMP = 700;
|
|
1161
|
+
/* The collapsing half of a bubble, on its own so that bubbles which lead with a
|
|
1162
|
+
header — a verdict, a named claim — can clamp their body without clamping the
|
|
1163
|
+
header along with it. */
|
|
1164
|
+
/**
|
|
1165
|
+
* Every message in full, always. Nothing is truncated and nothing hides behind
|
|
1166
|
+
* a button: reading a review should not mean clicking through it.
|
|
1167
|
+
*
|
|
1168
|
+
* The reason a clamp existed is still real — evidence arrives verbatim from
|
|
1169
|
+
* `jury finding reproduce --evidence` and is routinely thousands of lines, and
|
|
1170
|
+
* one such turn inlined at full height buries every turn after it. So a long
|
|
1171
|
+
* message scrolls WITHIN ITS OWN BOX instead of being cut: the whole text is in
|
|
1172
|
+
* the DOM, selectable and searchable with the browser's own find, while the
|
|
1173
|
+
* thread below it stays reachable. Bounded height, not bounded content.
|
|
1174
|
+
*/
|
|
1175
|
+
function clamped(text) {
|
|
1176
|
+
const t = text ?? "";
|
|
1177
|
+
if (t.length <= CLAMP) return esc(t);
|
|
1178
|
+
return `<div class="tall" tabindex="0" role="region"
|
|
1179
|
+
aria-label="long message, ${bytes(t.length)}, scrollable">${esc(t)}</div>`;
|
|
1180
|
+
}
|
|
1181
|
+
function bubble(text, k, attrs = "") {
|
|
1182
|
+
return `<div class="bub" data-k="${k}" ${attrs}>${clamped(text)}</div>`;
|
|
1183
|
+
}
|
|
1184
|
+
/** m:ss, or h:mm:ss once a reviewer has been going that long. */
|
|
1185
|
+
function hms(sec) {
|
|
1186
|
+
const s = Math.max(0, Math.floor(sec));
|
|
1187
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
|
|
1188
|
+
return h
|
|
1189
|
+
? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`
|
|
1190
|
+
: `${m}:${String(r).padStart(2, "0")}`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; }
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* When a turn was spoken.
|
|
1197
|
+
*
|
|
1198
|
+
* Relative inline, absolute on hover. An offset ("+4m") is what you want when
|
|
1199
|
+
* reading the exchange as a conversation; the wall clock is what you want when
|
|
1200
|
+
* correlating against a log, and that is the rarer need. Pinned to SGT like
|
|
1201
|
+
* everything else here: a review record should read the same wherever it is
|
|
1202
|
+
* opened.
|
|
1203
|
+
*/
|
|
1204
|
+
let CONVO_T0 = null;
|
|
1205
|
+
let CONVO_SPREAD = 0;
|
|
1206
|
+
let CONVO_PREV = null;
|
|
1207
|
+
function stampFor(t) {
|
|
1208
|
+
if (!t?.ts) return "";
|
|
1209
|
+
const at = Date.parse(t.ts);
|
|
1210
|
+
if (Number.isNaN(at)) return "";
|
|
1211
|
+
const clock = clockFmt.format(new Date(at));
|
|
1212
|
+
const abs = `${clock} ${TZ_LABEL}`;
|
|
1213
|
+
// Offsets only say something when the turn is actually separated from what
|
|
1214
|
+
// came before it. Every finding parsed from one report is appended in the
|
|
1215
|
+
// same millisecond, so a whole batch reads "+0s" — truthful and useless.
|
|
1216
|
+
//
|
|
1217
|
+
// Decided per turn, not per thread. Keying it on the thread's total spread
|
|
1218
|
+
// was all-or-nothing: one slow triage pushed the spread over a minute and
|
|
1219
|
+
// then the same-instant batch of findings ABOVE it rendered "+0s" anyway,
|
|
1220
|
+
// which is the case this rule exists to avoid.
|
|
1221
|
+
if (CONVO_T0 === null || CONVO_SPREAD < 60000) {
|
|
1222
|
+
return `<time title="${esc(abs)}">${esc(clock)}</time>`;
|
|
1223
|
+
}
|
|
1224
|
+
const d = Math.max(0, Math.round((at - CONVO_T0) / 1000));
|
|
1225
|
+
// Within a minute of the turn before it, this one carries no offset worth
|
|
1226
|
+
// reading; the wall clock is the more informative of the two.
|
|
1227
|
+
const gap = CONVO_PREV === null ? d * 1000 : at - CONVO_PREV;
|
|
1228
|
+
CONVO_PREV = at;
|
|
1229
|
+
if (d === 0 || gap < 60000) {
|
|
1230
|
+
return `<time title="${esc(abs)}">${esc(clock)}</time>`;
|
|
1231
|
+
}
|
|
1232
|
+
const rel = d < 60 ? `+${d}s` : d < 3600 ? `+${Math.round(d / 60)}m` : `+${(d / 3600).toFixed(1)}h`;
|
|
1233
|
+
return `<time title="${esc(abs)}">${esc(rel)}</time>`;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function turnHTML(t, agent) {
|
|
1237
|
+
const side = SIDE(t.who);
|
|
1238
|
+
const nm = t.who === "claude" ? "claude" : agent;
|
|
1239
|
+
let body = "";
|
|
1240
|
+
switch (t.kind) {
|
|
1241
|
+
case "finding":
|
|
1242
|
+
body = `<div class="bub" data-k="finding"><span class="hd">${esc(t.claim ?? "")}</span
|
|
1243
|
+
>${t.loc ? `<span class="loc">${esc(t.loc)}</span>\n` : ""}${esc((t.body ?? "").slice(0, CLAMP))}</div>`;
|
|
1244
|
+
break;
|
|
1245
|
+
case "reproduced":
|
|
1246
|
+
// Evidence is whatever the reviewer passed to `finding reproduce
|
|
1247
|
+
// --evidence`, which is routinely a wall of captured command output. It
|
|
1248
|
+
// collapses, or one reproduction buries every turn below it.
|
|
1249
|
+
body = `<div class="bub" data-k="reproduced"
|
|
1250
|
+
>${t.claim ? `<span class="re">${esc(t.claim)}</span>\n` : ""}Reproduced. ${clamped(t.text ?? "")}</div>`;
|
|
1251
|
+
break;
|
|
1252
|
+
case "verdict":
|
|
1253
|
+
// The claim is named here because the findings are no longer bubbles of
|
|
1254
|
+
// their own: without it a verdict floats free of what it answers.
|
|
1255
|
+
body = `<div class="bub" data-k="verdict" data-v="${esc(t.verdict ?? "")}"
|
|
1256
|
+
><span class="hd">${esc(VERDICT_TEXT[t.verdict] ?? t.verdict ?? "")}</span
|
|
1257
|
+
>${t.claim ? `<span class="re">${esc(t.claim)}</span>\n` : ""}${esc(t.text ?? "")}${t.test ? `\n\nRegression test: ${esc(t.test)} — verified failing with the fix reverted.` : ""}</div>`;
|
|
1258
|
+
break;
|
|
1259
|
+
case "rebuttal":
|
|
1260
|
+
// A reviewer's rebuttal carries no text of its own: it was an excerpt of
|
|
1261
|
+
// the reply rendered directly above it. What it marks is that the claim is
|
|
1262
|
+
// being re-argued, so it names the claim and leaves the words to the
|
|
1263
|
+
// report. Claude's turn in the thread does carry text, and shows it.
|
|
1264
|
+
body = `<div class="bub" data-k="rebuttal"
|
|
1265
|
+
><span class="hd">${t.who === "claude" ? "still open" : "not convinced"}</span
|
|
1266
|
+
>${t.claim ? `<span class="re">${esc(t.claim)}</span>` : ""}${t.text ? `\n${esc(t.text)}` : ""}</div>`;
|
|
1267
|
+
break;
|
|
1268
|
+
case "commit":
|
|
1269
|
+
body = `<div class="bub" data-k="commit">pushed ${esc(t.sha ?? "")}${t.text ? ` · ${esc(t.text)}` : ""}</div>`;
|
|
1270
|
+
break;
|
|
1271
|
+
case "waiting": {
|
|
1272
|
+
// Counted up client-side between heartbeats, so it ticks every second
|
|
1273
|
+
// rather than jumping in five-second steps.
|
|
1274
|
+
// An agent that streamed and then went quiet says so: "working 6:12 ·
|
|
1275
|
+
// quiet 2m" is the difference between thinking and wedged, and the
|
|
1276
|
+
// heartbeat alone cannot express it.
|
|
1277
|
+
const quiet = t.quietSeconds
|
|
1278
|
+
? ` <span class="q">· quiet ${esc(hms(t.quietSeconds))}</span>`
|
|
1279
|
+
: "";
|
|
1280
|
+
body = `<div class="bub" data-k="waiting" data-since="${Date.now() - (t.seconds ?? 0) * 1000}"
|
|
1281
|
+
>working <b class="tick">${esc(hms(t.seconds ?? 0))}</b>${quiet}</div>`;
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
case "streaming":
|
|
1285
|
+
// The tail only: a live agent's stdout is the transcript so far, and the
|
|
1286
|
+
// interesting part is always the end of it.
|
|
1287
|
+
body = `<div class="bub" data-k="streaming">${clamped(t.text ?? "")}</div>`;
|
|
1288
|
+
break;
|
|
1289
|
+
case "report":
|
|
1290
|
+
body = bubble(t.text ?? "", "report");
|
|
1291
|
+
break;
|
|
1292
|
+
case "reply":
|
|
1293
|
+
body = bubble(t.text ?? "(reply sent)", "reply");
|
|
1294
|
+
break;
|
|
1295
|
+
case "answer":
|
|
1296
|
+
body = bubble(t.text ?? "", "answer");
|
|
1297
|
+
break;
|
|
1298
|
+
default:
|
|
1299
|
+
body = bubble(t.text ?? "", esc(t.kind ?? "turn"));
|
|
1300
|
+
}
|
|
1301
|
+
return `<div class="msg" data-side="${side}"><span class="nm"
|
|
1302
|
+
><span class="ic" style="color:${nm === "claude" ? "var(--main-hue)" : hueFor(nm)}">${icon(nm)}</span>${esc(nm)}${stampFor(t)}</span>${body}</div>`;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* `?view=conversation` renders the thread alone, filling the window, so it can
|
|
1307
|
+
* sit in a second window beside the terminal while a round runs. It is the same
|
|
1308
|
+
* fold and the same stream as the embedded view — CSS hides the rest of the
|
|
1309
|
+
* page — so there is no second implementation to keep in step.
|
|
1310
|
+
*/
|
|
1311
|
+
const POPPED = new URL(location.href).searchParams.get("view") === "conversation";
|
|
1312
|
+
if (POPPED) document.body.dataset.view = "conversation";
|
|
1313
|
+
|
|
1314
|
+
function drawConvo() {
|
|
1315
|
+
const tabs = $("convo-tabs"), grid = $("convo-grid"), sec = $("convo-sec");
|
|
1316
|
+
if (!tabs || !grid) return;
|
|
1317
|
+
const threads = THREADS.filter((t) => t.agent && t.agent !== "?");
|
|
1318
|
+
// Popped out there is nothing else on the page, so hiding the only section
|
|
1319
|
+
// would leave a blank window rather than an empty-looking one.
|
|
1320
|
+
if (!threads.length) { sec.style.display = POPPED ? "" : "none"; return; }
|
|
1321
|
+
sec.style.display = "";
|
|
1322
|
+
|
|
1323
|
+
// The popout carries the run it was opened from, so the link is shareable and
|
|
1324
|
+
// survives a reload.
|
|
1325
|
+
const pop = $("convo-pop");
|
|
1326
|
+
if (pop) {
|
|
1327
|
+
const u = new URL(location.href);
|
|
1328
|
+
u.searchParams.set("view", "conversation");
|
|
1329
|
+
if (RUN?.slug) u.searchParams.set("run", RUN.slug);
|
|
1330
|
+
pop.href = u.pathname + u.search;
|
|
1331
|
+
}
|
|
1332
|
+
if (POPPED) {
|
|
1333
|
+
const who = threads.map((t) => t.agent).join(", ");
|
|
1334
|
+
document.title = RUN?.target?.id
|
|
1335
|
+
? `${RUN.target.id} · ${who} — conversation`
|
|
1336
|
+
: "conversation";
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const names = threads.map((t) => t.agent);
|
|
1340
|
+
if (!names.includes(CONVO_PICK)) CONVO_PICK = names[0];
|
|
1341
|
+
const live = (a) => threads.find((t) => t.agent === a)?.turns.some((x) => x.kind === "streaming");
|
|
1342
|
+
|
|
1343
|
+
// The count sits on the tab because with five reviewers the question is
|
|
1344
|
+
// which one to read, and that is answerable from turn count and liveness
|
|
1345
|
+
// without opening any of them.
|
|
1346
|
+
tabs.innerHTML = names.map((a) => {
|
|
1347
|
+
const t = threads.find((x) => x.agent === a);
|
|
1348
|
+
const on = !CONVO_STACK && CONVO_PICK === a;
|
|
1349
|
+
// The agent's own mark, not a coloured dot: with five reviewers the name is
|
|
1350
|
+
// the slowest thing on the chip to read, and an unknown agent still gets a
|
|
1351
|
+
// legible fallback glyph.
|
|
1352
|
+
return `<button class="pr-chip" data-ctab="${esc(a)}" aria-pressed="${on}"
|
|
1353
|
+
><span class="ic" style="color:${hueFor(a)}">${icon(a)}</span
|
|
1354
|
+
>${esc(a)}<span class="n" data-live="${live(a) ? 1 : 0}">${t.turns.length}</span></button>`;
|
|
1355
|
+
}).join("");
|
|
1356
|
+
|
|
1357
|
+
const btn = $("convo-stack");
|
|
1358
|
+
if (btn) {
|
|
1359
|
+
btn.setAttribute("aria-pressed", String(CONVO_STACK));
|
|
1360
|
+
btn.textContent = CONVO_STACK ? `one at a time` : `stack all ${names.length}`;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const shown = CONVO_STACK ? threads : threads.filter((t) => t.agent === CONVO_PICK);
|
|
1364
|
+
grid.dataset.stack = CONVO_STACK ? "1" : "0";
|
|
1365
|
+
grid.innerHTML = shown.map((t) => {
|
|
1366
|
+
const open = !CONVO_STACK || !CONVO_SHUT.has(t.agent);
|
|
1367
|
+
const l = live(t.agent);
|
|
1368
|
+
// Offsets are measured from this thread's own first turn, not the run's:
|
|
1369
|
+
// each conversation reads from its own beginning.
|
|
1370
|
+
const times = t.turns.map((x) => x.ts).filter(Boolean).map(Date.parse)
|
|
1371
|
+
.filter((n) => !Number.isNaN(n)).sort((a, b) => a - b);
|
|
1372
|
+
CONVO_T0 = times[0] ?? null;
|
|
1373
|
+
CONVO_SPREAD = times.length > 1 ? times[times.length - 1] - times[0] : 0;
|
|
1374
|
+
// Per thread: turns render in order, so each stamp compares against the
|
|
1375
|
+
// previous turn of THIS conversation.
|
|
1376
|
+
CONVO_PREV = null;
|
|
1377
|
+
const msgs = t.turns.length
|
|
1378
|
+
? t.turns.map((x) => turnHTML(x, t.agent)).join("")
|
|
1379
|
+
: `<p class="empty">waiting for ${esc(t.agent)}…</p>`;
|
|
1380
|
+
return `<div class="convo" data-a="${esc(t.agent)}" data-open="${open ? 1 : 0}">
|
|
1381
|
+
<header data-fold-agent="${esc(t.agent)}"><span class="tw">${open ? "▾" : "▸"}</span
|
|
1382
|
+
><span class="d" data-live="${l ? 1 : 0}"></span>
|
|
1383
|
+
<span class="who"><span class="ic" style="color:var(--main-hue)">${icon("claude")}</span>claude
|
|
1384
|
+
<span class="sw">⇄</span>
|
|
1385
|
+
<span class="ic" style="color:${hueFor(t.agent)}">${icon(t.agent)}</span>${esc(t.agent)}</span>
|
|
1386
|
+
<span class="st">${t.turns.length} turn${t.turns.length === 1 ? "" : "s"}${l ? " · live" : ""}</span>
|
|
1387
|
+
</header>
|
|
1388
|
+
<div class="msgs" data-agent="${esc(t.agent)}">${msgs}</div>
|
|
1389
|
+
</div>`;
|
|
1390
|
+
}).join("");
|
|
1391
|
+
|
|
1392
|
+
for (const box of grid.querySelectorAll(".msgs")) {
|
|
1393
|
+
if (box.dataset.at !== "0") box.scrollTop = box.scrollHeight;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
document.addEventListener("click", (e) => {
|
|
1398
|
+
const tab = e.target.closest("[data-ctab]");
|
|
1399
|
+
if (tab) {
|
|
1400
|
+
// Picking a reviewer while stacked means "show me that one", not "keep
|
|
1401
|
+
// everything stacked and scroll to it".
|
|
1402
|
+
CONVO_STACK = false;
|
|
1403
|
+
CONVO_PICK = tab.dataset.ctab;
|
|
1404
|
+
// The exchange below follows this selection, so it has to move with it.
|
|
1405
|
+
drawConvo(); draw();
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (e.target.closest("#convo-stack")) { CONVO_STACK = !CONVO_STACK; drawConvo(); draw(); return; }
|
|
1409
|
+
const fold = e.target.closest("[data-fold-agent]");
|
|
1410
|
+
if (fold && CONVO_STACK) {
|
|
1411
|
+
const a = fold.dataset.foldAgent;
|
|
1412
|
+
CONVO_SHUT.has(a) ? CONVO_SHUT.delete(a) : CONVO_SHUT.add(a);
|
|
1413
|
+
drawConvo();
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
// Remember whether each column was pinned to the bottom before a re-render.
|
|
1419
|
+
document.addEventListener("scroll", (e) => {
|
|
1420
|
+
const box = e.target.closest?.(".msgs");
|
|
1421
|
+
if (!box) return;
|
|
1422
|
+
const atEnd = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
|
|
1423
|
+
box.dataset.at = atEnd ? "1" : "0";
|
|
1424
|
+
}, true);
|
|
1425
|
+
|
|
1426
|
+
/* Live events. The append-only log is the transport: the server replays it on
|
|
1427
|
+
connect and pushes each new line, so a page opened mid-round catches up and
|
|
1428
|
+
then follows in real time. Reconnects on drop — a run outlives any one
|
|
1429
|
+
connection, and a console that silently stops updating is worse than one
|
|
1430
|
+
that never started. */
|
|
1431
|
+
// One timer for every waiting bubble on the page. The heartbeat lands every
|
|
1432
|
+
// five seconds; this makes the display move every second in between, so a
|
|
1433
|
+
// reviewer that is alive looks alive rather than frozen.
|
|
1434
|
+
setInterval(() => {
|
|
1435
|
+
for (const el of document.querySelectorAll('.bub[data-k="waiting"]')) {
|
|
1436
|
+
const since = Number(el.dataset.since);
|
|
1437
|
+
if (!since) continue;
|
|
1438
|
+
const b = el.querySelector(".tick");
|
|
1439
|
+
if (b) b.textContent = hms((Date.now() - since) / 1000);
|
|
1440
|
+
}
|
|
1441
|
+
}, 1000);
|
|
1442
|
+
|
|
1443
|
+
let ES = null;
|
|
1444
|
+
let ES_SLUG = null;
|
|
1445
|
+
let EVENTS = [];
|
|
1446
|
+
let REPLAY = []; // events arriving during a reconnect replay
|
|
1447
|
+
let replaying = true; // the first batch after connecting is always a replay
|
|
1448
|
+
|
|
1449
|
+
function connect(slug) {
|
|
1450
|
+
if (!slug || slug === ES_SLUG) return;
|
|
1451
|
+
ES_SLUG = slug;
|
|
1452
|
+
ES?.close();
|
|
1453
|
+
EVENTS = [];
|
|
1454
|
+
REPLAY = [];
|
|
1455
|
+
replaying = true;
|
|
1456
|
+
try {
|
|
1457
|
+
ES = new EventSource(`api/stream?run=${encodeURIComponent(slug)}`);
|
|
1458
|
+
} catch { return; }
|
|
1459
|
+
ES.addEventListener("event", (m) => {
|
|
1460
|
+
let e;
|
|
1461
|
+
try { e = JSON.parse(m.data); } catch { return; }
|
|
1462
|
+
(replaying ? REPLAY : EVENTS).push(e);
|
|
1463
|
+
if (!replaying) schedule();
|
|
1464
|
+
});
|
|
1465
|
+
// EventSource reconnects on its own, and the server replays the log from byte
|
|
1466
|
+
// zero every time it does. Appending that replay to history we already hold
|
|
1467
|
+
// duplicated every finding and reply once per dropped connection, and the
|
|
1468
|
+
// duplicates compounded. "ready" ends a replay, so the events that arrived
|
|
1469
|
+
// during it ARE the whole history: keep those and drop what came before.
|
|
1470
|
+
ES.addEventListener("ready", () => {
|
|
1471
|
+
if (REPLAY.length) { EVENTS = REPLAY.slice(); REPLAY = []; }
|
|
1472
|
+
replaying = false;
|
|
1473
|
+
schedule();
|
|
1474
|
+
});
|
|
1475
|
+
ES.onerror = () => {
|
|
1476
|
+
// The next batch is a fresh replay, not a continuation.
|
|
1477
|
+
replaying = true;
|
|
1478
|
+
REPLAY = [];
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Chunks arrive faster than anyone can read. Coalesce a burst into one paint.
|
|
1483
|
+
let pending = null;
|
|
1484
|
+
function schedule() {
|
|
1485
|
+
if (pending) return;
|
|
1486
|
+
pending = setTimeout(() => {
|
|
1487
|
+
pending = null;
|
|
1488
|
+
THREADS = foldConversation(EVENTS);
|
|
1489
|
+
drawConvo();
|
|
1490
|
+
}, 120);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/* The same fold as lib/loop.js conversation(), in the browser: applying it to
|
|
1494
|
+
the live stream avoids a round trip per token, and the server-side copy stays
|
|
1495
|
+
authoritative for anything that reads a finished run. */
|
|
1496
|
+
function foldConversation(events) {
|
|
1497
|
+
const threads = new Map();
|
|
1498
|
+
const thread = (a) => {
|
|
1499
|
+
let t = threads.get(a);
|
|
1500
|
+
if (!t) threads.set(a, (t = { agent: a, turns: [] }));
|
|
1501
|
+
return t;
|
|
1502
|
+
};
|
|
1503
|
+
const owner = new Map();
|
|
1504
|
+
const claimOf = new Map();
|
|
1505
|
+
for (const e of events) {
|
|
1506
|
+
switch (e.t) {
|
|
1507
|
+
case "finding.raised":
|
|
1508
|
+
owner.set(e.id, e.agent);
|
|
1509
|
+
// Deliberately NOT a turn of its own. The reviewer already said this in
|
|
1510
|
+
// its report, verbatim and in its own shape; a parsed copy underneath
|
|
1511
|
+
// is the same words twice. The claim is kept only so a verdict further
|
|
1512
|
+
// down can name what it is answering.
|
|
1513
|
+
claimOf.set(e.id, e.claim ?? "");
|
|
1514
|
+
// The reviewer still gets a thread even if this is the only thing it
|
|
1515
|
+
// ever emitted: in production a report always precedes its findings,
|
|
1516
|
+
// but a console that hides a reviewer entirely when it does not is
|
|
1517
|
+
// worse than one that shows an empty column.
|
|
1518
|
+
thread(e.agent);
|
|
1519
|
+
break;
|
|
1520
|
+
case "finding.reproduced":
|
|
1521
|
+
thread(owner.get(e.id) ?? "?").turns.push({ who: "claude", kind: "reproduced",
|
|
1522
|
+
id: e.id, claim: claimOf.get(e.id) ?? "",
|
|
1523
|
+
text: typeof e.evidence === "string" ? e.evidence : "", ts: e.ts });
|
|
1524
|
+
break;
|
|
1525
|
+
case "finding.resolved":
|
|
1526
|
+
thread(owner.get(e.id) ?? "?").turns.push({ who: "claude", kind: "verdict",
|
|
1527
|
+
id: e.id, claim: claimOf.get(e.id) ?? "",
|
|
1528
|
+
verdict: e.verdict, text: e.reason ?? "", test: e.test ?? "", ts: e.ts });
|
|
1529
|
+
break;
|
|
1530
|
+
case "finding.turn": {
|
|
1531
|
+
// A reviewer's rebuttal is an excerpt cut from the reply it arrived in,
|
|
1532
|
+
// and that reply is already rendered whole as its `answer` turn — so the
|
|
1533
|
+
// text is deliberately dropped, exactly as a raised finding's is. What
|
|
1534
|
+
// survives is the linkage: which claim is being re-argued, and by whom.
|
|
1535
|
+
// Claude's own turn is the exception: it is not an excerpt of anything
|
|
1536
|
+
// shown elsewhere, so dropping its text would lose it entirely.
|
|
1537
|
+
const who = e.who ?? "?";
|
|
1538
|
+
const turn = { who, kind: "rebuttal", id: e.id,
|
|
1539
|
+
claim: claimOf.get(e.id) ?? "", ts: e.ts };
|
|
1540
|
+
if (who === "claude") turn.text = e.text ?? "";
|
|
1541
|
+
thread(owner.get(e.id) ?? e.agent ?? "?").turns.push(turn);
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
// A reviewer that does not stream. codex writes nothing until it exits,
|
|
1545
|
+
// so without this its column is empty for nine minutes and "thinking" is
|
|
1546
|
+
// indistinguishable from "wedged".
|
|
1547
|
+
case "agent.alive": {
|
|
1548
|
+
// `forAgent` names the reviewer whose finding is being judged, so the
|
|
1549
|
+
// main agent's heartbeat lands inside that reviewer's conversation
|
|
1550
|
+
// rather than opening a thread of its own.
|
|
1551
|
+
const t = thread(e.forAgent ?? e.agent);
|
|
1552
|
+
// Found by round, not by position. Two rounds running concurrently
|
|
1553
|
+
// interleave their heartbeats, so "is it the last turn?" failed on
|
|
1554
|
+
// every alternation and a nine-minute round grew 81 bubbles instead of
|
|
1555
|
+
// one.
|
|
1556
|
+
const w = t.turns.find(
|
|
1557
|
+
(x) => x.kind === "waiting" && x.round === e.round && x.who === e.agent,
|
|
1558
|
+
);
|
|
1559
|
+
if (w) { w.seconds = e.seconds; w.quietSeconds = e.quietSeconds ?? null; break; }
|
|
1560
|
+
// Real output already supersedes it; do not regress to a placeholder.
|
|
1561
|
+
// Per speaker, not per thread: the reviewer's report ends the reviewer's
|
|
1562
|
+
// turn, but the main agent triaging afterwards is a new speaker on the
|
|
1563
|
+
// same thread — keying this on the thread dropped every triage
|
|
1564
|
+
// heartbeat, which is the longest silence in the round and the reason
|
|
1565
|
+
// the heartbeat exists.
|
|
1566
|
+
if (t.turns.some((x) => (x.kind === "streaming" || x.kind === "report") && x.round === e.round && x.who === e.agent)) break;
|
|
1567
|
+
t.turns.push({ who: e.agent, kind: "waiting", round: e.round, seconds: e.seconds, ts: e.ts });
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
case "agent.chunk": {
|
|
1571
|
+
const t = thread(e.agent);
|
|
1572
|
+
const last = t.turns[t.turns.length - 1];
|
|
1573
|
+
if (last?.kind === "streaming" && last.round === e.round) last.text += e.text ?? "";
|
|
1574
|
+
else {
|
|
1575
|
+
const w = t.turns.findIndex((x) => x.kind === "waiting" && x.round === e.round);
|
|
1576
|
+
const turn = { who: e.agent, kind: "streaming", round: e.round, text: e.text ?? "", ts: e.ts };
|
|
1577
|
+
if (w >= 0) t.turns.splice(w, 1, turn); else t.turns.push(turn);
|
|
1578
|
+
}
|
|
1579
|
+
break;
|
|
1580
|
+
}
|
|
1581
|
+
case "agent.report": {
|
|
1582
|
+
const t = thread(e.agent);
|
|
1583
|
+
const i = t.turns.findIndex(
|
|
1584
|
+
(x) => (x.kind === "streaming" || x.kind === "waiting") && x.round === e.round,
|
|
1585
|
+
);
|
|
1586
|
+
const turn = { who: e.agent, kind: "report", round: e.round,
|
|
1587
|
+
verdict: e.verdict, text: e.report ?? "", seconds: e.seconds, ts: e.ts };
|
|
1588
|
+
if (i >= 0) t.turns.splice(i, 1, turn); else t.turns.push(turn);
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
case "reply.sent":
|
|
1592
|
+
thread(e.agent).turns.push({ who: "claude", kind: "reply", text: e.text ?? "", ts: e.ts });
|
|
1593
|
+
break;
|
|
1594
|
+
case "reply.answered":
|
|
1595
|
+
thread(e.agent).turns.push({ who: e.agent, kind: "answer",
|
|
1596
|
+
text: e.report ?? "", verdict: e.verdict, seconds: e.seconds, ts: e.ts });
|
|
1597
|
+
break;
|
|
1598
|
+
case "commit.pushed":
|
|
1599
|
+
for (const t of threads.values()) {
|
|
1600
|
+
t.turns.push({ who: "claude", kind: "commit", sha: e.sha, text: e.subject ?? "", ts: e.ts });
|
|
1601
|
+
}
|
|
1602
|
+
break;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return [...threads.values()];
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
let ALL = [RUN];
|
|
1609
|
+
let sig = null; // last payload seen, so an unchanged poll costs nothing
|
|
1610
|
+
let assetStamp = null; // index.html mtime; a change means reload, not re-render
|
|
1611
|
+
let served = false;
|
|
1612
|
+
|
|
1613
|
+
/** Put the selected run in the query string, as a history entry you can go back through. */
|
|
1614
|
+
function setRunParam(slug, { replace = false } = {}) {
|
|
1615
|
+
if (!slug) return;
|
|
1616
|
+
const url = new URL(location.href);
|
|
1617
|
+
if (url.searchParams.get("run") === slug) return;
|
|
1618
|
+
url.searchParams.set("run", slug);
|
|
1619
|
+
history[replace ? "replaceState" : "pushState"]({ run: slug }, "", url);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** The run named by ?run=, if it is one we actually have. */
|
|
1623
|
+
function runFromParam(runs) {
|
|
1624
|
+
const want = new URL(location.href).searchParams.get("run");
|
|
1625
|
+
if (!want) return null;
|
|
1626
|
+
return runs.find((r) => r.slug === want) ?? null;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Back and forward move between PRs rather than leaving the console.
|
|
1630
|
+
addEventListener("popstate", () => {
|
|
1631
|
+
const pick = runFromParam(ALL);
|
|
1632
|
+
if (pick && pick !== RUN) { RUN = pick; render(); }
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
function stamp(note) {
|
|
1636
|
+
const el = $("t-kicker");
|
|
1637
|
+
if (!el || !served) return;
|
|
1638
|
+
const t = new Date().toLocaleTimeString();
|
|
1639
|
+
el.textContent = `${el.textContent.replace(/ · (updated|live).*$/, "")} · updated ${t}${note ?? ""}`;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
async function poll({ first = false } = {}) {
|
|
1643
|
+
let payload = null;
|
|
1644
|
+
try {
|
|
1645
|
+
const res = await fetch("api/run", { cache: "no-store" });
|
|
1646
|
+
if (res.ok) payload = await res.text();
|
|
1647
|
+
} catch (_) {
|
|
1648
|
+
return false; // standalone, or the server went away — keep what is on screen
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// The page itself may have been rebuilt while this tab was open.
|
|
1652
|
+
try {
|
|
1653
|
+
const h = await (await fetch("api/health", { cache: "no-store" })).json();
|
|
1654
|
+
if (h?.assets) {
|
|
1655
|
+
if (assetStamp && h.assets !== assetStamp) { location.reload(); return true; }
|
|
1656
|
+
assetStamp = h.assets;
|
|
1657
|
+
}
|
|
1658
|
+
} catch (_) { /* health is optional */ }
|
|
1659
|
+
|
|
1660
|
+
if (payload === null) return first ? false : true;
|
|
1661
|
+
served = true;
|
|
1662
|
+
if (payload === sig) { stamp(); return true; }
|
|
1663
|
+
sig = payload;
|
|
1664
|
+
|
|
1665
|
+
let live;
|
|
1666
|
+
try { live = JSON.parse(payload); } catch { return true; }
|
|
1667
|
+
const runs = Array.isArray(live?.targets) ? live.targets : live ? [live] : [];
|
|
1668
|
+
const usable = runs.filter((x) => x && x.target && Array.isArray(x.rounds));
|
|
1669
|
+
if (!usable.length) return true;
|
|
1670
|
+
|
|
1671
|
+
// Keep whatever the reader was looking at when new runs arrive. ?run= wins:
|
|
1672
|
+
// it is an explicit request for one PR, and a poll that quietly moved off it
|
|
1673
|
+
// would make the URL a lie.
|
|
1674
|
+
const keep = RUN?.slug;
|
|
1675
|
+
ALL = usable;
|
|
1676
|
+
RUN = runFromParam(usable)
|
|
1677
|
+
?? usable.find((r) => r.slug === keep)
|
|
1678
|
+
?? usable[0];
|
|
1679
|
+
// Stamp the address on first load so what is on screen is always linkable,
|
|
1680
|
+
// without pushing a history entry nobody asked for.
|
|
1681
|
+
setRunParam(RUN?.slug, { replace: true });
|
|
1682
|
+
render();
|
|
1683
|
+
stamp();
|
|
1684
|
+
return true;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
(async () => {
|
|
1688
|
+
const ok = await poll({ first: true });
|
|
1689
|
+
render();
|
|
1690
|
+
if (!ok) return; // opened standalone: nothing to poll, embedded run stands
|
|
1691
|
+
|
|
1692
|
+
let timer = setInterval(poll, 3000);
|
|
1693
|
+
// Do not poll a tab nobody is looking at; refresh at once when it returns.
|
|
1694
|
+
document.addEventListener("visibilitychange", () => {
|
|
1695
|
+
clearInterval(timer);
|
|
1696
|
+
if (!document.hidden) { poll(); timer = setInterval(poll, 3000); }
|
|
1697
|
+
});
|
|
1698
|
+
})();
|
|
1699
|
+
</script>
|