skill-harness 0.13.0 → 0.14.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/README.md +4 -1
- package/assets/report.grade.js +177 -0
- package/assets/report.template.html +470 -0
- package/dist/index.js +17311 -0
- package/dist/prompt-capture-extension.js +118 -0
- package/package.json +10 -2
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>skill-harness · __SKILL__</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0f1115; --panel: #171a21; --line: #262b36; --fg: #e6e9ef; --dim: #9aa3b2;
|
|
10
|
+
--pass: #1f8b4c; --fail: #c0392b; --err: #6b7280; --accent: #4f8cff; --over: #d28b00;
|
|
11
|
+
}
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
|
14
|
+
header { padding: 16px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
15
|
+
header h1 { font-size: 18px; margin: 0; font-weight: 650; }
|
|
16
|
+
header .sub { color: var(--dim); font-size: 13px; }
|
|
17
|
+
main { display: flex; min-height: calc(100vh - 58px); }
|
|
18
|
+
.matrix-wrap { flex: 1; overflow: auto; padding: 16px 20px; }
|
|
19
|
+
table { border-collapse: collapse; width: 100%; }
|
|
20
|
+
th, td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
|
|
21
|
+
th { background: var(--panel); font-weight: 600; position: sticky; top: 0; }
|
|
22
|
+
th.scn, td.scn { position: sticky; left: 0; background: var(--panel); z-index: 1; min-width: 200px; }
|
|
23
|
+
.crit { color: var(--over); margin-left: 4px; }
|
|
24
|
+
.cell { cursor: pointer; text-align: center; font-weight: 650; min-width: 90px; user-select: none; }
|
|
25
|
+
.cell.PASS { background: rgba(31,139,76,.22); }
|
|
26
|
+
.cell.FAIL { background: rgba(192,57,43,.24); }
|
|
27
|
+
.cell.ERROR { background: rgba(107,114,128,.22); }
|
|
28
|
+
.cell.NOT-MEASURED { background: rgba(180,83,9,.18); }
|
|
29
|
+
.cell.empty { color: var(--dim); cursor: default; font-weight: 400; }
|
|
30
|
+
.cell .ov { display: block; font-size: 11px; color: var(--over); font-weight: 600; }
|
|
31
|
+
.cell .ov.unsaved { color: #b45309; font-style: italic; }
|
|
32
|
+
.cell .ov.suspect { color: #b45309; }
|
|
33
|
+
.cell .reps { display: block; font-size: 10px; color: var(--dim); }
|
|
34
|
+
.cell.sel { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
35
|
+
.grade { font-size: 12px; color: var(--dim); font-weight: 500; margin-top: 4px; }
|
|
36
|
+
.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
|
37
|
+
.badge.ship { background: var(--pass); color: #fff; }
|
|
38
|
+
.badge.no { background: var(--fail); color: #fff; }
|
|
39
|
+
/* red-vs-green lift: what the skill itself changed */
|
|
40
|
+
.lift { font-size: 11px; margin-top: 3px; font-weight: 600; }
|
|
41
|
+
.lift.up { color: var(--pass); }
|
|
42
|
+
.lift.down { color: var(--fail); }
|
|
43
|
+
.lift.flat, .lift.none { color: var(--dim); font-weight: 500; }
|
|
44
|
+
.cell .lm { display: block; font-size: 10px; font-weight: 700; }
|
|
45
|
+
.cell .lm.up { color: var(--pass); }
|
|
46
|
+
.cell .lm.down { color: var(--fail); }
|
|
47
|
+
/* Amber, like the suspect badge: both mean "this verdict is worth less than it looks". */
|
|
48
|
+
.cell .lm.flip { color: #b45309; }
|
|
49
|
+
/* Objective trace gates: pass/fail are facts about what the model did. ERROR is
|
|
50
|
+
amber rather than red — missing evidence is not a failing gate. */
|
|
51
|
+
.cell .lm.obj-pass { color: var(--pass); }
|
|
52
|
+
.cell .lm.obj-fail { color: var(--fail); }
|
|
53
|
+
.cell .lm.obj-error, .cell .lm.obj-not-measured { color: #b45309; }
|
|
54
|
+
/* Adjudication. `unresolved` is amber for the same reason `suspect` is: it must
|
|
55
|
+
never read as a clean PASS or FAIL. */
|
|
56
|
+
.cell .lm.adj-settled { color: var(--dim); }
|
|
57
|
+
.cell .lm.adj-unresolved { color: #b45309; }
|
|
58
|
+
aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
|
|
59
|
+
aside.open { width: 460px; }
|
|
60
|
+
.panel { width: 460px; padding: 16px 18px; }
|
|
61
|
+
.panel h2 { font-size: 15px; margin: 0 0 2px; }
|
|
62
|
+
.panel .meta { color: var(--dim); font-size: 12px; margin-bottom: 12px; }
|
|
63
|
+
.panel .reason { background: #11141a; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin-bottom: 12px; }
|
|
64
|
+
.toggle { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
65
|
+
.toggle button { flex: 1; padding: 7px; border: 1px solid var(--line); background: #11141a; color: var(--fg); border-radius: 6px; cursor: pointer; font-weight: 600; }
|
|
66
|
+
.toggle button.active.PASS { background: var(--pass); border-color: var(--pass); color: #fff; }
|
|
67
|
+
.toggle button.active.FAIL { background: var(--fail); border-color: var(--fail); color: #fff; }
|
|
68
|
+
.toggle button.active.JUDGE { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
69
|
+
textarea { width: 100%; min-height: 60px; background: #11141a; color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font: inherit; resize: vertical; }
|
|
70
|
+
label.fld { display: block; font-size: 12px; color: var(--dim); margin: 10px 0 4px; }
|
|
71
|
+
pre.transcript { white-space: pre-wrap; word-break: break-word; background: #0b0d11; border: 1px solid var(--line); border-radius: 6px; padding: 10px; max-height: 42vh; overflow: auto; font: 12px/1.5 ui-monospace, monospace; }
|
|
72
|
+
.saved { color: var(--pass); font-size: 12px; margin-left: 8px; opacity: 0; transition: opacity .2s; }
|
|
73
|
+
.saved.show { opacity: 1; }
|
|
74
|
+
.saved.err { color: #b91c1c; opacity: 1; }
|
|
75
|
+
.close { float: right; cursor: pointer; color: var(--dim); border: none; background: none; font-size: 18px; }
|
|
76
|
+
.empty-state { color: var(--dim); padding: 40px; text-align: center; }
|
|
77
|
+
#misfires { margin: 0 0 16px; }
|
|
78
|
+
.mf-title { font-weight: 600; margin-bottom: 6px; }
|
|
79
|
+
.mf-title.empty { color: var(--dim); font-weight: 400; }
|
|
80
|
+
.mf-row { padding: 4px 0; border-bottom: 1px solid var(--line, #eee); font-size: 13px; }
|
|
81
|
+
.rejudge { margin: 8px 0; }
|
|
82
|
+
.rejudge button { cursor: pointer; }
|
|
83
|
+
#trends-section { margin-top: 20px; }
|
|
84
|
+
#trends-toggle { cursor: pointer; font-weight: 600; background: none; border: none; font-size: 14px; }
|
|
85
|
+
.tmodel { margin: 12px 0; }
|
|
86
|
+
.tmodel-h { font-size: 13px; margin-bottom: 4px; }
|
|
87
|
+
.spark { vertical-align: middle; color: var(--dim, #888); }
|
|
88
|
+
table.tgrid { border-collapse: collapse; font-size: 12px; }
|
|
89
|
+
/* The trends grid is a compact read-only summary, not the interactive matrix
|
|
90
|
+
above — it deliberately opts out of the matrix's sticky-header/sticky-
|
|
91
|
+
first-column rules (`position: sticky`) so a future CSS consolidation
|
|
92
|
+
doesn't reintroduce the 094a219 bug (trends scrolling under a stuck header). */
|
|
93
|
+
.tgrid th, .tgrid td { padding: 2px 6px; text-align: center; position: static; }
|
|
94
|
+
.tgrid td.tscn { text-align: left; }
|
|
95
|
+
.tgrid td.tc.PASS { color: #16a34a; }
|
|
96
|
+
.tgrid td.tc.FAIL { color: #dc2626; }
|
|
97
|
+
.tgrid td.tc.ERROR { color: #a855f7; }
|
|
98
|
+
.tgrid td.tc.NOT-MEASURED { color: #b45309; }
|
|
99
|
+
.tgrid td.tc.suspect { color: #b45309; }
|
|
100
|
+
.tgrid td.tc.absent { color: var(--dim, #bbb); }
|
|
101
|
+
.dim { color: var(--dim, #888); font-weight: 400; }
|
|
102
|
+
</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<header>
|
|
106
|
+
<h1>skill-harness</h1>
|
|
107
|
+
<span class="sub" id="subtitle"></span>
|
|
108
|
+
</header>
|
|
109
|
+
<main>
|
|
110
|
+
<div class="matrix-wrap">
|
|
111
|
+
<div id="misfires"></div>
|
|
112
|
+
<div id="matrix"></div>
|
|
113
|
+
<div id="trends-section">
|
|
114
|
+
<button id="trends-toggle">▸ Trends</button>
|
|
115
|
+
<div id="trends" hidden></div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<aside id="aside"><div class="panel" id="panel"></div></aside>
|
|
119
|
+
</main>
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
const DATA = /*__DATA__*/null;
|
|
123
|
+
|
|
124
|
+
// effective()/gradeColumn() are injected here from assets/report.grade.js —
|
|
125
|
+
// the single, tested copy of the client scorer (mirrors src/score.ts). See
|
|
126
|
+
// renderReport() in packages/core/src/report.ts for how the injection works.
|
|
127
|
+
/*__GRADE__*/
|
|
128
|
+
|
|
129
|
+
let selected = null; // { colIndex, scenarioId }
|
|
130
|
+
|
|
131
|
+
function render() {
|
|
132
|
+
document.getElementById("subtitle").textContent =
|
|
133
|
+
`${DATA.skill} · ${DATA.columns.length} model(s) · ship bar ${DATA.shipBar.min_pass}/${DATA.shipBar.total}, no critical fail`;
|
|
134
|
+
const wrap = document.getElementById("matrix");
|
|
135
|
+
if (!DATA.columns.length) {
|
|
136
|
+
wrap.innerHTML = `<div class="empty-state">No runs yet for <b>${DATA.skill}</b>.<br>Run <code>skill-harness run ${DATA.skill}</code> first.</div>`;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const grades = DATA.columns.map((col) => gradeColumn(col, DATA.shipBar, DATA.critical));
|
|
140
|
+
const queue = [];
|
|
141
|
+
DATA.columns.forEach((col) => {
|
|
142
|
+
// A suspect cell in a red column can't be re-judged from here (/rejudge 400s
|
|
143
|
+
// for an unscored baseline) — don't queue it. The matrix below still shows
|
|
144
|
+
// suspect badges for every column regardless of mode.
|
|
145
|
+
if (col.mode && col.mode !== "green" && col.mode !== "force") return;
|
|
146
|
+
for (const scn of DATA.scenarios) {
|
|
147
|
+
const cell = col.cells[scn.id];
|
|
148
|
+
if (cell && cell.suspect && !cell.override) queue.push({ col: col.index, id: scn.id, label: col.label, reason: cell.judge_reason });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const mf = document.getElementById("misfires");
|
|
152
|
+
if (mf) {
|
|
153
|
+
mf.innerHTML = queue.length
|
|
154
|
+
? `<div class="mf-title">⚠ Misfire queue (${queue.length})</div>` + queue.map((q) =>
|
|
155
|
+
`<div class="mf-row" data-col="${q.col}" data-id="${escapeHtml(q.id)}"><b>${escapeHtml(q.id)}</b> · ${escapeHtml(q.label)} — ${escapeHtml(q.reason || "(no reason)")} <button class="mf-open">inspect</button></div>`
|
|
156
|
+
).join("")
|
|
157
|
+
: `<div class="mf-title empty">No unresolved misfires.</div>`;
|
|
158
|
+
mf.querySelectorAll(".mf-row").forEach((row) => {
|
|
159
|
+
row.querySelector(".mf-open").onclick = () => openPanel(+row.dataset.col, row.dataset.id);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
let html = "<table><thead><tr><th class='scn'>scenario</th>";
|
|
163
|
+
DATA.columns.forEach((col, i) => {
|
|
164
|
+
const g = grades[i];
|
|
165
|
+
let gradeHtml;
|
|
166
|
+
if (col.mode && col.mode !== "green" && col.mode !== "force") {
|
|
167
|
+
// Green and force are scored; a red baseline column has no ship grade.
|
|
168
|
+
gradeHtml = `<span class='badge no'>not scored (${escapeHtml(col.mode)})</span>`;
|
|
169
|
+
if (g.suspect > 0) gradeHtml += ` — ${g.suspect} suspect`;
|
|
170
|
+
if (g.errors > 0) gradeHtml += ` — ${g.errors} infrastructure error`;
|
|
171
|
+
} else {
|
|
172
|
+
// g.ship is already false whenever g.suspect > 0 (gradeColumn's suspect gate), so
|
|
173
|
+
// the NOT READY badge is forced automatically; we just surface the count here.
|
|
174
|
+
const badge = g.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
|
|
175
|
+
const suspectNote = g.suspect > 0 ? ` — ${g.suspect} suspect` : "";
|
|
176
|
+
const errorNote = g.errors > 0 ? ` — ${g.errors} infrastructure error${g.errors === 1 ? "" : "s"}` : "";
|
|
177
|
+
gradeHtml = `${g.letter} (${g.pct}%) · ${g.passed}/${g.total}${suspectNote}${errorNote} ${badge}`;
|
|
178
|
+
}
|
|
179
|
+
const m = col.metrics;
|
|
180
|
+
const metricHtml = m && m.total_reps > 0
|
|
181
|
+
? `<div class='grade' title='subject metrics reported by ${m.subject_metrics_reps}/${m.total_reps} reps'>` +
|
|
182
|
+
`${m.input_tokens == null ? "tokens n/a" : `${m.input_tokens}in/${m.output_tokens || 0}out`} · ` +
|
|
183
|
+
`${m.judge_calls} judge · ${m.wall_time_ms}ms · ${m.tool_calls == null ? "tools n/a" : `${m.tool_calls} tools`}</div>`
|
|
184
|
+
: "";
|
|
185
|
+
// Lift answers "does this skill do anything?", which the grade alone cannot:
|
|
186
|
+
// a column can score A because the model never needed the skill. Recomputed
|
|
187
|
+
// from the live cells (see liftSummary) so overrides move it immediately.
|
|
188
|
+
const ls = liftSummary(col);
|
|
189
|
+
let liftHtml = "";
|
|
190
|
+
if (ls && ls.compared > 0) {
|
|
191
|
+
const moved = ls.gained > 0 || ls.regressed > 0;
|
|
192
|
+
const cls = !moved ? "flat" : ls.delta > 0 ? "up" : ls.delta < 0 ? "down" : "flat";
|
|
193
|
+
// All-inconclusive is no measurement, not a zero effect — don't claim "no
|
|
194
|
+
// effect" when nothing was measurable (mirrors liftHeadline in lift.ts).
|
|
195
|
+
const body = ls.compared === ls.inconclusive
|
|
196
|
+
? `lift — nothing conclusive`
|
|
197
|
+
: moved
|
|
198
|
+
? `lift ${ls.delta > 0 ? "+" : ""}${ls.delta} · ${ls.gained}↑ ${ls.regressed}↓`
|
|
199
|
+
: `lift 0 · no effect`;
|
|
200
|
+
const inc = ls.inconclusive > 0 ? ` · ${ls.inconclusive} inconclusive` : "";
|
|
201
|
+
const part = col.lift.partial ? " · partial" : "";
|
|
202
|
+
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${metricHtml}` +
|
|
203
|
+
`<div class='lift ${cls}' title='vs red baseline ${escapeHtml(col.lift.redTimestamp)} — ${ls.kept} passed without the skill too'>${body}${inc}${part}</div></th>`;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// "not measured" is a different claim from "measured no effect" — and an
|
|
207
|
+
// unusable baseline is a third (see liftNoneBadge).
|
|
208
|
+
const none = liftNoneBadge(col);
|
|
209
|
+
if (none) {
|
|
210
|
+
liftHtml = `<div class='lift none' title='${escapeHtml(none.title)}'>${escapeHtml(none.text)}</div>`;
|
|
211
|
+
}
|
|
212
|
+
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${metricHtml}${liftHtml}</th>`;
|
|
213
|
+
});
|
|
214
|
+
html += "</tr></thead><tbody>";
|
|
215
|
+
for (const scn of DATA.scenarios) {
|
|
216
|
+
html += `<tr><td class='scn'>${scn.id}${scn.critical ? "<span class='crit' title='critical'>⚠</span>" : ""}<div class='grade'>${escapeHtml(scn.title)}</div></td>`;
|
|
217
|
+
DATA.columns.forEach((col) => {
|
|
218
|
+
const cell = col.cells[scn.id];
|
|
219
|
+
// `return`, not `continue`: this is a forEach callback, so `continue` is a
|
|
220
|
+
// SyntaxError that takes the entire inline script — the whole review UI —
|
|
221
|
+
// down with it. Guarded by report.test.ts's "rendered report is valid
|
|
222
|
+
// JavaScript".
|
|
223
|
+
if (!cell) { html += `<td class='cell empty'>–</td>`; return; }
|
|
224
|
+
const v = effective(cell);
|
|
225
|
+
const sel = selected && selected.colIndex === col.index && selected.scenarioId === scn.id ? " sel" : "";
|
|
226
|
+
// _unsaved wins regardless of override value, so a failed *clear*
|
|
227
|
+
// (override → null) still shows the marker instead of looking persisted.
|
|
228
|
+
const ov = cell._unsaved
|
|
229
|
+
? `<span class='ov unsaved'>unsaved</span>`
|
|
230
|
+
: (cell.override ? `<span class='ov'>override</span>` : "");
|
|
231
|
+
const misfired = cell.clean != null && cell.clean < cell.reps ? ` · ${cell.reps - cell.clean} misfired` : "";
|
|
232
|
+
const reps = cell.reps ? `<span class='reps'>${cell.passes}/${cell.clean}${misfired}${cell.flakiness ? ` · flaky ${cell.flakiness.toFixed(2)}` : ""}</span>` : "";
|
|
233
|
+
const suspectBadge = cell.suspect && !cell.override ? `<span class='ov suspect'>suspect</span>` : "";
|
|
234
|
+
// Per-cell lift marker: only the two classes that mean the skill changed
|
|
235
|
+
// the outcome. kept/both-fail/inconclusive are left unmarked — marking
|
|
236
|
+
// every cell would bury the signal this column exists to show.
|
|
237
|
+
const lc = col.lift && col.lift.cells[scn.id] ? liftClass(col.lift.cells[scn.id], cell) : null;
|
|
238
|
+
const liftMark =
|
|
239
|
+
lc === "gained"
|
|
240
|
+
? `<span class='lm up' title='red baseline ${col.lift.cells[scn.id].red} → PASS with the skill'>↑ skill</span>`
|
|
241
|
+
: lc === "regressed"
|
|
242
|
+
? `<span class='lm down' title='red baseline PASS → ${effective(cell)} with the skill'>↓ skill</span>`
|
|
243
|
+
: "";
|
|
244
|
+
// Run-over-run marker. Only on cells that actually flipped, and deliberately
|
|
245
|
+
// NOT folded into the flakiness number beside it: `flaky 0.00` is a within-run
|
|
246
|
+
// measure, and the case this exists for is a cell that was unanimous in every
|
|
247
|
+
// run and still landed on a different side each time.
|
|
248
|
+
const st = cell.stability;
|
|
249
|
+
const stabMark = st
|
|
250
|
+
? `<span class='lm flip' title='${escapeHtml(st.note)}'>⇄ ${st.flips}/${st.compared}</span>`
|
|
251
|
+
: "";
|
|
252
|
+
// Objective trace gates. Only rendered when the scenario DECLARED them —
|
|
253
|
+
// absent must read as "not declared", never as an objective pass.
|
|
254
|
+
const obj = cell.objective;
|
|
255
|
+
const objMark = obj
|
|
256
|
+
? `<span class='lm obj-${obj.status.toLowerCase()}' title='${escapeHtml(`objective ${obj.status}: ${obj.detail}`)}'>◉ ${escapeHtml(obj.status.toLowerCase())}</span>`
|
|
257
|
+
: "";
|
|
258
|
+
// Adjudication. `unresolved` is amber and says so in words — a disagreement
|
|
259
|
+
// that renders as a clean verdict is the whole failure this feature prevents.
|
|
260
|
+
const adj = cell.adjudication;
|
|
261
|
+
const adjMark = adj
|
|
262
|
+
? `<span class='lm adj-${adj.state === "unresolved" ? "unresolved" : "settled"}' title='${escapeHtml(`adjudication ${adj.state} (${adj.trigger}) · ${adj.detail}`)}'>⚖ ${adj.state === "unresolved" ? "unresolved" : `${adj.count} agree`}</span>`
|
|
263
|
+
: "";
|
|
264
|
+
html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${stabMark}${objMark}${adjMark}${reps}</td>`;
|
|
265
|
+
});
|
|
266
|
+
html += "</tr>";
|
|
267
|
+
}
|
|
268
|
+
html += "</tbody></table>";
|
|
269
|
+
wrap.innerHTML = html;
|
|
270
|
+
wrap.querySelectorAll(".cell:not(.empty)").forEach((td) => {
|
|
271
|
+
td.addEventListener("click", () => openPanel(+td.dataset.col, td.dataset.id));
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function openPanel(colIndex, scenarioId) {
|
|
276
|
+
selected = { colIndex, scenarioId };
|
|
277
|
+
render();
|
|
278
|
+
const col = DATA.columns.find((c) => c.index === colIndex);
|
|
279
|
+
const scn = DATA.scenarios.find((s) => s.id === scenarioId);
|
|
280
|
+
const cell = col.cells[scenarioId];
|
|
281
|
+
const aside = document.getElementById("aside");
|
|
282
|
+
const panel = document.getElementById("panel");
|
|
283
|
+
aside.classList.add("open");
|
|
284
|
+
const eff = effective(cell);
|
|
285
|
+
panel.innerHTML = `
|
|
286
|
+
<button class="close" id="closeBtn">×</button>
|
|
287
|
+
<h2>${scn.id} · ${escapeHtml(scn.title)}</h2>
|
|
288
|
+
<div class="meta">${escapeHtml(col.label)} · mode ${escapeHtml(col.mode || "green")} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
|
|
289
|
+
<div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
|
|
290
|
+
${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
|
|
291
|
+
${cell.stability ? `<div class="reason" style="color:#b45309"><b>⇄ boundary cell:</b> ${escapeHtml(cell.stability.note)}</div>` : ""}
|
|
292
|
+
${cell.objective ? `<div class="reason"><b>◉ objective ${escapeHtml(cell.objective.status)}:</b> ${escapeHtml(cell.objective.detail)}</div>` : ""}
|
|
293
|
+
${cell.adjudication ? `<div class="reason"${cell.adjudication.state === "unresolved" ? ' style="color:#b45309"' : ""}><b>⚖ adjudication ${escapeHtml(cell.adjudication.state)}</b> (${escapeHtml(cell.adjudication.trigger)}): ${escapeHtml(cell.adjudication.detail)}</div>` : ""}
|
|
294
|
+
${cell.metrics ? `<div class="reason"><b>cost/latency:</b> ${cell.metrics.input_tokens == null ? "subject tokens unavailable" : `${cell.metrics.input_tokens} input / ${cell.metrics.output_tokens || 0} output / ${cell.metrics.cache_read_tokens || 0} cache-read`} (${cell.metrics.subject_metrics_reps}/${cell.metrics.total_reps} reps) · ${cell.metrics.judge_calls} judge + ${cell.metrics.judge_rejudge_calls} re-judge · ${cell.metrics.wall_time_ms}ms · ${cell.metrics.tool_calls == null ? "tool calls unavailable" : `${cell.metrics.tool_calls} tools, ${cell.metrics.delegated_children || 0} delegated, max concurrency ${cell.metrics.max_concurrency || 0}`}</div>` : ""}
|
|
295
|
+
<div class="toggle">
|
|
296
|
+
<button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
|
|
297
|
+
<button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
|
|
298
|
+
<button data-v="" class="JUDGE ${!cell.override ? 'active JUDGE' : ''}">use judge (${cell.judge_verdict})</button>
|
|
299
|
+
</div>
|
|
300
|
+
<div class="rejudge"><button id="rejudgeBtn">Re-judge (${escapeHtml(col.judge.provider + ":" + col.judge.model)})</button> <span class="saved" id="rejudged"></span></div>
|
|
301
|
+
<div class="rejudge"><button id="adjBtn">Adjudicate column…</button> <span class="saved" id="adjudged"></span></div>
|
|
302
|
+
<label class="fld">note <span class="saved" id="saved">saved ✓</span></label>
|
|
303
|
+
<textarea id="note" placeholder="why you overrode / what to fix in SKILL.md">${escapeHtml(cell.note || "")}</textarea>
|
|
304
|
+
<label class="fld">transcript</label>
|
|
305
|
+
<pre class="transcript" id="transcript">loading…</pre>
|
|
306
|
+
<label class="fld">judge raw</label>
|
|
307
|
+
<pre class="transcript" id="judgeraw">loading…</pre>
|
|
308
|
+
`;
|
|
309
|
+
document.getElementById("closeBtn").onclick = () => { aside.classList.remove("open"); selected = null; render(); };
|
|
310
|
+
panel.querySelectorAll(".toggle button").forEach((b) => {
|
|
311
|
+
b.onclick = () => { cell.override = b.dataset.v || null; save(col, scenarioId, cell); openPanel(colIndex, scenarioId); };
|
|
312
|
+
});
|
|
313
|
+
const rj = document.getElementById("rejudgeBtn");
|
|
314
|
+
if (rj) rj.onclick = async () => {
|
|
315
|
+
rj.disabled = true; rj.textContent = "re-judging…";
|
|
316
|
+
try {
|
|
317
|
+
const r = await fetch("/rejudge", {
|
|
318
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
319
|
+
body: JSON.stringify({ col: colIndex, scenarioId }),
|
|
320
|
+
});
|
|
321
|
+
const body = await r.json().catch(() => ({}));
|
|
322
|
+
if (!r.ok) { const s = document.getElementById("rejudged"); if (s) { s.textContent = body.error || "re-judge failed"; s.classList.add("show", "err"); } rj.disabled = false; rj.textContent = "Re-judge"; return; }
|
|
323
|
+
location.reload(); // re-judge rewrote results.yaml; reload the fresh matrix + grades
|
|
324
|
+
} catch (e) { rj.disabled = false; rj.textContent = "Re-judge"; }
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Two requests on purpose. The first prices the work and spends nothing; the
|
|
328
|
+
// confirm shows that exact ceiling; only then does anything call a judge. A
|
|
329
|
+
// single endpoint could not disclose a count before charging for it.
|
|
330
|
+
const adjBtn = document.getElementById("adjBtn");
|
|
331
|
+
if (adjBtn) adjBtn.onclick = async () => {
|
|
332
|
+
const status = document.getElementById("adjudged");
|
|
333
|
+
const show = (msg, err) => { if (status) { status.textContent = msg; status.classList.toggle("err", !!err); status.classList.add("show"); } };
|
|
334
|
+
adjBtn.disabled = true;
|
|
335
|
+
try {
|
|
336
|
+
const pr = await fetch("/adjudicate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ col: colIndex, step: "plan" }) });
|
|
337
|
+
const plan = await pr.json().catch(() => ({}));
|
|
338
|
+
if (!pr.ok || !plan.ok) { show(plan.error || "could not plan adjudication", true); adjBtn.disabled = false; return; }
|
|
339
|
+
if (!plan.triggered.length) { show("no cell triggered — nothing to adjudicate", false); adjBtn.disabled = false; return; }
|
|
340
|
+
|
|
341
|
+
// Counts, never dollars: the default judge is a Claude subscription and
|
|
342
|
+
// reports no per-call usage, so a cost figure here would be invented.
|
|
343
|
+
const ok = window.confirm(
|
|
344
|
+
`Adjudicate ${plan.triggered.length} cell(s) with ${plan.judge}?\n\n` +
|
|
345
|
+
`Up to ${plan.maxAdditionalCalls} additional judge call(s).\n` +
|
|
346
|
+
`No tie-break judge from the browser — a disagreement stays unresolved and blocks SHIP.\n\n` +
|
|
347
|
+
plan.detail.join("\n")
|
|
348
|
+
);
|
|
349
|
+
if (!ok) { show("cancelled — nothing spent", false); adjBtn.disabled = false; return; }
|
|
350
|
+
|
|
351
|
+
adjBtn.textContent = "Adjudicating…";
|
|
352
|
+
const rr = await fetch("/adjudicate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ col: colIndex, step: "run" }) });
|
|
353
|
+
const done = await rr.json().catch(() => ({}));
|
|
354
|
+
if (!rr.ok || !done.ok) { show(done.error || "adjudication failed", true); adjBtn.disabled = false; adjBtn.textContent = "Adjudicate column…"; return; }
|
|
355
|
+
location.reload();
|
|
356
|
+
} catch (e) {
|
|
357
|
+
show("adjudication failed", true); adjBtn.disabled = false; adjBtn.textContent = "Adjudicate column…";
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
let t;
|
|
361
|
+
document.getElementById("note").addEventListener("input", (e) => {
|
|
362
|
+
cell.note = e.target.value;
|
|
363
|
+
clearTimeout(t);
|
|
364
|
+
t = setTimeout(() => save(col, scenarioId, cell), 500);
|
|
365
|
+
});
|
|
366
|
+
try {
|
|
367
|
+
const r = await fetch(`/transcript?col=${colIndex}&id=${encodeURIComponent(scenarioId)}`);
|
|
368
|
+
document.getElementById("transcript").textContent = r.ok ? await r.text() : "(transcript unavailable)";
|
|
369
|
+
} catch { document.getElementById("transcript").textContent = "(transcript unavailable)"; }
|
|
370
|
+
try {
|
|
371
|
+
const jr = await fetch(`/judge?col=${colIndex}&id=${encodeURIComponent(scenarioId)}`);
|
|
372
|
+
document.getElementById("judgeraw").textContent = jr.ok ? await jr.text() : "(judge output not captured)";
|
|
373
|
+
} catch { document.getElementById("judgeraw").textContent = "(judge output not captured)"; }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function save(col, scenarioId, cell) {
|
|
377
|
+
const s = document.getElementById("saved");
|
|
378
|
+
// Optimistic: mark the cell unsaved until the server confirms. On a rejected
|
|
379
|
+
// save (e.g. note-less override → 400) the marker stays, so the matrix never
|
|
380
|
+
// shows an override as persisted when results.yaml still has it null. The
|
|
381
|
+
// local toggle is kept so typing a note then retriggers save.
|
|
382
|
+
cell._unsaved = true;
|
|
383
|
+
try {
|
|
384
|
+
const r = await fetch("/save", {
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: { "content-type": "application/json" },
|
|
387
|
+
body: JSON.stringify({ col: col.index, scenarioId, override: cell.override, note: cell.note }),
|
|
388
|
+
});
|
|
389
|
+
if (!r.ok) {
|
|
390
|
+
const body = await r.json().catch(() => ({}));
|
|
391
|
+
if (s) { s.textContent = body.error || "save failed"; s.classList.add("show", "err"); }
|
|
392
|
+
} else {
|
|
393
|
+
cell._unsaved = false;
|
|
394
|
+
if (s) { s.textContent = "saved ✓"; s.classList.remove("err"); s.classList.add("show"); setTimeout(() => s.classList.remove("show"), 1200); }
|
|
395
|
+
}
|
|
396
|
+
} catch (e) { console.error("save failed", e); }
|
|
397
|
+
render();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
401
|
+
|
|
402
|
+
let trendsLoaded = false;
|
|
403
|
+
document.getElementById("trends-toggle").onclick = async () => {
|
|
404
|
+
const box = document.getElementById("trends");
|
|
405
|
+
const btn = document.getElementById("trends-toggle");
|
|
406
|
+
const nowHidden = box.hasAttribute("hidden");
|
|
407
|
+
if (nowHidden) box.removeAttribute("hidden"); else box.setAttribute("hidden", "");
|
|
408
|
+
btn.textContent = (nowHidden ? "▾ Trends" : "▸ Trends");
|
|
409
|
+
if (nowHidden && !trendsLoaded) {
|
|
410
|
+
box.textContent = "loading…";
|
|
411
|
+
try {
|
|
412
|
+
const r = await fetch("/trends");
|
|
413
|
+
if (!r.ok) throw new Error(`trends fetch failed: ${r.status} ${await r.text().catch(() => "")}`);
|
|
414
|
+
renderTrends(await r.json());
|
|
415
|
+
trendsLoaded = true;
|
|
416
|
+
} catch (e) { console.error("trends load failed", e); box.textContent = "(trends unavailable)"; }
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
function sparkline(runs) {
|
|
421
|
+
if (!runs.length) return "";
|
|
422
|
+
const w = 8 * runs.length + 4, h = 24;
|
|
423
|
+
if (runs.length === 1) {
|
|
424
|
+
const cx = 4, cy = h - 2 - (runs[0].grade.pct / 100) * (h - 4);
|
|
425
|
+
return `<svg width="${w}" height="${h}" class="spark"><circle cx="${cx}" cy="${cy}" r="2" fill="currentColor"/></svg>`;
|
|
426
|
+
}
|
|
427
|
+
const pts = runs.map((r, i) => `${4 + i * 8},${h - 2 - (r.grade.pct / 100) * (h - 4)}`).join(" ");
|
|
428
|
+
return `<svg width="${w}" height="${h}" class="spark"><polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function renderTrends(data) {
|
|
432
|
+
const box = document.getElementById("trends");
|
|
433
|
+
if (!data.models.length) { box.innerHTML = `<div class="empty-state">No runs yet.</div>`; return; }
|
|
434
|
+
const glyph = { PASS: "✓", FAIL: "✗", ERROR: "!", "NOT-MEASURED": "∅" };
|
|
435
|
+
let html = "";
|
|
436
|
+
for (const m of data.models) {
|
|
437
|
+
const last = m.runs[m.runs.length - 1].grade;
|
|
438
|
+
const badge = last.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
|
|
439
|
+
const trunc = m.truncated ? ` <span class='dim'>(last ${m.runs.length})</span>` : "";
|
|
440
|
+
const skippedNote = m.skipped > 0 ? ` <span class='dim'>(${m.skipped} unreadable)</span>` : "";
|
|
441
|
+
// The mode is part of the series identity, not decoration: a tag that moved
|
|
442
|
+
// from green to force delivery has TWO series here, and the same skill text
|
|
443
|
+
// scores differently under each — so an unlabelled pair of sparklines would
|
|
444
|
+
// read as one history that jumped.
|
|
445
|
+
const modeNote = m.mode ? ` <span class='dim'>${escapeHtml(m.mode)}</span>` : "";
|
|
446
|
+
html += `<div class="tmodel"><div class="tmodel-h">${escapeHtml(m.model)}${modeNote} — ${sparkline(m.runs)} ${last.letter} (${last.pct}%) ${badge}${trunc}${skippedNote}</div>`;
|
|
447
|
+
html += "<table class='tgrid'><thead><tr><th></th>";
|
|
448
|
+
for (const run of m.runs) html += `<th title="${escapeHtml(run.label || run.timestamp)}">${escapeHtml((run.label || run.timestamp).slice(0, 8))}</th>`;
|
|
449
|
+
html += "</tr></thead><tbody>";
|
|
450
|
+
for (const scn of data.scenarios) {
|
|
451
|
+
html += `<tr><td class='tscn'>${escapeHtml(scn.id)}</td>`;
|
|
452
|
+
for (const run of m.runs) {
|
|
453
|
+
const cell = run.cells[scn.id];
|
|
454
|
+
if (!cell) { html += `<td class='tc absent'>·</td>`; continue; }
|
|
455
|
+
const g = cell.suspect ? "?" : (glyph[cell.verdict] || "?");
|
|
456
|
+
const cls = escapeHtml(cell.suspect ? "suspect" : cell.verdict);
|
|
457
|
+
const title = `${run.label || run.timestamp}${cell.flakiness != null ? ` · flaky ${cell.flakiness.toFixed(2)}` : ""}`;
|
|
458
|
+
html += `<td class='tc ${cls}' title="${escapeHtml(title)}">${g}</td>`;
|
|
459
|
+
}
|
|
460
|
+
html += "</tr>";
|
|
461
|
+
}
|
|
462
|
+
html += "</tbody></table></div>";
|
|
463
|
+
}
|
|
464
|
+
box.innerHTML = html;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
render();
|
|
468
|
+
</script>
|
|
469
|
+
</body>
|
|
470
|
+
</html>
|