@skill-harness/cli 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mojo Manyana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ // Client-side scorer for the review UI (assets/report.template.html).
2
+ //
3
+ // This is the SINGLE source of truth for "how does a column get graded in the
4
+ // browser" — it must implement exactly the same rules as
5
+ // packages/core/src/score.ts's `score()`. It is:
6
+ // (a) imported directly, as plain ESM, by
7
+ // packages/core/test/grade-column-parity.test.ts, which asserts parity
8
+ // against score.ts for a set of fixtures (PASS/FAIL/critical/B-series/
9
+ // suspect/override-resolved-suspect). If you change score.ts's rules
10
+ // and forget to mirror them here, that test fails.
11
+ // (b) injected verbatim into report.template.html's inline <script>, at the
12
+ // GRADE placeholder comment near its top (see renderReport in
13
+ // packages/core/src/report.ts).
14
+ //
15
+ // INJECTION NOTE: a bare inline <script> (no type="module") cannot contain an
16
+ // `export` statement. renderReport() strips the leading `export ` keyword off
17
+ // each exported declaration textually before splicing this file's contents
18
+ // into the template. Nothing here relies on import/export semantics at
19
+ // runtime (no imports, no re-exports), so stripping `export ` and leaving
20
+ // plain function declarations behind is safe in both the browser (global
21
+ // script scope) and Node (this file imported as an ES module).
22
+ //
23
+ // No DOM access, no imports — must load in Node (for the parity test) and in
24
+ // a plain <script> in the browser (for the review UI).
25
+
26
+ export function effective(cell) {
27
+ return cell.override || cell.judge_verdict;
28
+ }
29
+
30
+ function letterFor(pct) {
31
+ if (pct >= 90) return "A";
32
+ if (pct >= 80) return "B";
33
+ if (pct >= 70) return "C";
34
+ if (pct >= 60) return "D";
35
+ return "F";
36
+ }
37
+
38
+ /**
39
+ * Score one report column against the ship bar — mirrors score.ts's `score()`
40
+ * exactly, over `col.cells` (a scenario-id -> cell map) instead of a flat
41
+ * verdict list. A `suspect` cell without an override is excluded from both
42
+ * `passed` and `total` (untrustworthy: neither pass nor fail) and blocks ship.
43
+ */
44
+ export function gradeColumn(col, shipBar, critical) {
45
+ let passed = 0;
46
+ let total = 0;
47
+ let criticalFails = 0;
48
+ let bFails = 0;
49
+ let suspect = 0;
50
+
51
+ for (const id of Object.keys(col.cells)) {
52
+ const cell = col.cells[id];
53
+ if (!cell) continue;
54
+ if (cell.suspect && !cell.override) {
55
+ suspect++;
56
+ continue; // excluded, blocks ship
57
+ }
58
+ total++;
59
+ if (effective(cell) === "PASS") {
60
+ passed++;
61
+ continue;
62
+ }
63
+ if (critical.includes(id)) criticalFails++;
64
+ if (/^B/i.test(id)) bFails++;
65
+ }
66
+
67
+ const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
68
+ const letter = letterFor(pct);
69
+ const ship =
70
+ total >= shipBar.total &&
71
+ passed >= shipBar.min_pass &&
72
+ (!shipBar.no_critical_fail || criticalFails === 0) &&
73
+ bFails === 0 &&
74
+ suspect === 0;
75
+
76
+ return { passed, total, pct, letter, ship, criticalFails, bFails, suspect };
77
+ }
@@ -0,0 +1,336 @@
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.empty { color: var(--dim); cursor: default; font-weight: 400; }
29
+ .cell .ov { display: block; font-size: 11px; color: var(--over); font-weight: 600; }
30
+ .cell .ov.unsaved { color: #b45309; font-style: italic; }
31
+ .cell .ov.suspect { color: #b45309; }
32
+ .cell .reps { display: block; font-size: 10px; color: var(--dim); }
33
+ .cell.sel { outline: 2px solid var(--accent); outline-offset: -2px; }
34
+ .grade { font-size: 12px; color: var(--dim); font-weight: 500; margin-top: 4px; }
35
+ .badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; }
36
+ .badge.ship { background: var(--pass); color: #fff; }
37
+ .badge.no { background: var(--fail); color: #fff; }
38
+ aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
39
+ aside.open { width: 460px; }
40
+ .panel { width: 460px; padding: 16px 18px; }
41
+ .panel h2 { font-size: 15px; margin: 0 0 2px; }
42
+ .panel .meta { color: var(--dim); font-size: 12px; margin-bottom: 12px; }
43
+ .panel .reason { background: #11141a; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin-bottom: 12px; }
44
+ .toggle { display: flex; gap: 8px; margin-bottom: 12px; }
45
+ .toggle button { flex: 1; padding: 7px; border: 1px solid var(--line); background: #11141a; color: var(--fg); border-radius: 6px; cursor: pointer; font-weight: 600; }
46
+ .toggle button.active.PASS { background: var(--pass); border-color: var(--pass); color: #fff; }
47
+ .toggle button.active.FAIL { background: var(--fail); border-color: var(--fail); color: #fff; }
48
+ .toggle button.active.JUDGE { background: var(--accent); border-color: var(--accent); color: #fff; }
49
+ 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; }
50
+ label.fld { display: block; font-size: 12px; color: var(--dim); margin: 10px 0 4px; }
51
+ 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; }
52
+ .saved { color: var(--pass); font-size: 12px; margin-left: 8px; opacity: 0; transition: opacity .2s; }
53
+ .saved.show { opacity: 1; }
54
+ .saved.err { color: #b91c1c; opacity: 1; }
55
+ .close { float: right; cursor: pointer; color: var(--dim); border: none; background: none; font-size: 18px; }
56
+ .empty-state { color: var(--dim); padding: 40px; text-align: center; }
57
+ #misfires { margin: 0 0 16px; }
58
+ .mf-title { font-weight: 600; margin-bottom: 6px; }
59
+ .mf-title.empty { color: var(--dim); font-weight: 400; }
60
+ .mf-row { padding: 4px 0; border-bottom: 1px solid var(--line, #eee); font-size: 13px; }
61
+ .rejudge { margin: 8px 0; }
62
+ .rejudge button { cursor: pointer; }
63
+ #trends-section { margin-top: 20px; }
64
+ #trends-toggle { cursor: pointer; font-weight: 600; background: none; border: none; font-size: 14px; }
65
+ .tmodel { margin: 12px 0; }
66
+ .tmodel-h { font-size: 13px; margin-bottom: 4px; }
67
+ .spark { vertical-align: middle; color: var(--dim, #888); }
68
+ table.tgrid { border-collapse: collapse; font-size: 12px; }
69
+ /* The trends grid is a compact read-only summary, not the interactive matrix
70
+ above — it deliberately opts out of the matrix's sticky-header/sticky-
71
+ first-column rules (`position: sticky`) so a future CSS consolidation
72
+ doesn't reintroduce the 094a219 bug (trends scrolling under a stuck header). */
73
+ .tgrid th, .tgrid td { padding: 2px 6px; text-align: center; position: static; }
74
+ .tgrid td.tscn { text-align: left; }
75
+ .tgrid td.tc.PASS { color: #16a34a; }
76
+ .tgrid td.tc.FAIL { color: #dc2626; }
77
+ .tgrid td.tc.ERROR { color: #a855f7; }
78
+ .tgrid td.tc.suspect { color: #b45309; }
79
+ .tgrid td.tc.absent { color: var(--dim, #bbb); }
80
+ .dim { color: var(--dim, #888); font-weight: 400; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <header>
85
+ <h1>skill-harness</h1>
86
+ <span class="sub" id="subtitle"></span>
87
+ </header>
88
+ <main>
89
+ <div class="matrix-wrap">
90
+ <div id="misfires"></div>
91
+ <div id="matrix"></div>
92
+ <div id="trends-section">
93
+ <button id="trends-toggle">▸ Trends</button>
94
+ <div id="trends" hidden></div>
95
+ </div>
96
+ </div>
97
+ <aside id="aside"><div class="panel" id="panel"></div></aside>
98
+ </main>
99
+
100
+ <script>
101
+ const DATA = /*__DATA__*/null;
102
+
103
+ // effective()/gradeColumn() are injected here from assets/report.grade.js —
104
+ // the single, tested copy of the client scorer (mirrors src/score.ts). See
105
+ // renderReport() in packages/core/src/report.ts for how the injection works.
106
+ /*__GRADE__*/
107
+
108
+ let selected = null; // { colIndex, scenarioId }
109
+
110
+ function render() {
111
+ document.getElementById("subtitle").textContent =
112
+ `${DATA.skill} · ${DATA.columns.length} model(s) · ship bar ${DATA.shipBar.min_pass}/${DATA.shipBar.total}, no critical fail`;
113
+ const wrap = document.getElementById("matrix");
114
+ if (!DATA.columns.length) {
115
+ 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>`;
116
+ return;
117
+ }
118
+ const grades = DATA.columns.map((col) => gradeColumn(col, DATA.shipBar, DATA.critical));
119
+ const queue = [];
120
+ DATA.columns.forEach((col) => {
121
+ // A suspect cell in a red/force column can't be re-judged (/rejudge 400s
122
+ // "only green runs can be re-judged") — don't queue it. The matrix below
123
+ // still shows suspect badges for every column regardless of mode.
124
+ if (col.mode && col.mode !== "green") return;
125
+ for (const scn of DATA.scenarios) {
126
+ const cell = col.cells[scn.id];
127
+ if (cell && cell.suspect && !cell.override) queue.push({ col: col.index, id: scn.id, label: col.label, reason: cell.judge_reason });
128
+ }
129
+ });
130
+ const mf = document.getElementById("misfires");
131
+ if (mf) {
132
+ mf.innerHTML = queue.length
133
+ ? `<div class="mf-title">⚠ Misfire queue (${queue.length})</div>` + queue.map((q) =>
134
+ `<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>`
135
+ ).join("")
136
+ : `<div class="mf-title empty">No unresolved misfires.</div>`;
137
+ mf.querySelectorAll(".mf-row").forEach((row) => {
138
+ row.querySelector(".mf-open").onclick = () => openPanel(+row.dataset.col, row.dataset.id);
139
+ });
140
+ }
141
+ let html = "<table><thead><tr><th class='scn'>scenario</th>";
142
+ DATA.columns.forEach((col, i) => {
143
+ const g = grades[i];
144
+ let gradeHtml;
145
+ if (col.mode && col.mode !== "green") {
146
+ // Only green runs are scored; a red/force column has no ship grade.
147
+ gradeHtml = `<span class='badge no'>not scored (${escapeHtml(col.mode)})</span>`;
148
+ if (g.suspect > 0) gradeHtml += ` — ${g.suspect} suspect`;
149
+ } else {
150
+ // g.ship is already false whenever g.suspect > 0 (gradeColumn's suspect gate), so
151
+ // the NOT READY badge is forced automatically; we just surface the count here.
152
+ const badge = g.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
153
+ const suspectNote = g.suspect > 0 ? ` — ${g.suspect} suspect` : "";
154
+ gradeHtml = `${g.letter} (${g.pct}%) · ${g.passed}/${g.total}${suspectNote} ${badge}`;
155
+ }
156
+ html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div></th>`;
157
+ });
158
+ html += "</tr></thead><tbody>";
159
+ for (const scn of DATA.scenarios) {
160
+ html += `<tr><td class='scn'>${scn.id}${scn.critical ? "<span class='crit' title='critical'>⚠</span>" : ""}<div class='grade'>${escapeHtml(scn.title)}</div></td>`;
161
+ DATA.columns.forEach((col) => {
162
+ const cell = col.cells[scn.id];
163
+ if (!cell) { html += `<td class='cell empty'>–</td>`; continue; }
164
+ const v = effective(cell);
165
+ const sel = selected && selected.colIndex === col.index && selected.scenarioId === scn.id ? " sel" : "";
166
+ // _unsaved wins regardless of override value, so a failed *clear*
167
+ // (override → null) still shows the marker instead of looking persisted.
168
+ const ov = cell._unsaved
169
+ ? `<span class='ov unsaved'>unsaved</span>`
170
+ : (cell.override ? `<span class='ov'>override</span>` : "");
171
+ const misfired = cell.clean != null && cell.clean < cell.reps ? ` · ${cell.reps - cell.clean} misfired` : "";
172
+ const reps = cell.reps ? `<span class='reps'>${cell.passes}/${cell.clean}${misfired}${cell.flakiness ? ` · flaky ${cell.flakiness.toFixed(2)}` : ""}</span>` : "";
173
+ const suspectBadge = cell.suspect && !cell.override ? `<span class='ov suspect'>suspect</span>` : "";
174
+ html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${reps}</td>`;
175
+ });
176
+ html += "</tr>";
177
+ }
178
+ html += "</tbody></table>";
179
+ wrap.innerHTML = html;
180
+ wrap.querySelectorAll(".cell:not(.empty)").forEach((td) => {
181
+ td.addEventListener("click", () => openPanel(+td.dataset.col, td.dataset.id));
182
+ });
183
+ }
184
+
185
+ async function openPanel(colIndex, scenarioId) {
186
+ selected = { colIndex, scenarioId };
187
+ render();
188
+ const col = DATA.columns.find((c) => c.index === colIndex);
189
+ const scn = DATA.scenarios.find((s) => s.id === scenarioId);
190
+ const cell = col.cells[scenarioId];
191
+ const aside = document.getElementById("aside");
192
+ const panel = document.getElementById("panel");
193
+ aside.classList.add("open");
194
+ const eff = effective(cell);
195
+ panel.innerHTML = `
196
+ <button class="close" id="closeBtn">×</button>
197
+ <h2>${scn.id} · ${escapeHtml(scn.title)}</h2>
198
+ <div class="meta">${escapeHtml(col.label)} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
199
+ <div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
200
+ ${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
201
+ <div class="toggle">
202
+ <button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
203
+ <button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
204
+ <button data-v="" class="JUDGE ${!cell.override ? 'active JUDGE' : ''}">use judge (${cell.judge_verdict})</button>
205
+ </div>
206
+ <div class="rejudge"><button id="rejudgeBtn">Re-judge (${escapeHtml(col.judge.provider + ":" + col.judge.model)})</button> <span class="saved" id="rejudged"></span></div>
207
+ <label class="fld">note <span class="saved" id="saved">saved ✓</span></label>
208
+ <textarea id="note" placeholder="why you overrode / what to fix in SKILL.md">${escapeHtml(cell.note || "")}</textarea>
209
+ <label class="fld">transcript</label>
210
+ <pre class="transcript" id="transcript">loading…</pre>
211
+ <label class="fld">judge raw</label>
212
+ <pre class="transcript" id="judgeraw">loading…</pre>
213
+ `;
214
+ document.getElementById("closeBtn").onclick = () => { aside.classList.remove("open"); selected = null; render(); };
215
+ panel.querySelectorAll(".toggle button").forEach((b) => {
216
+ b.onclick = () => { cell.override = b.dataset.v || null; save(col, scenarioId, cell); openPanel(colIndex, scenarioId); };
217
+ });
218
+ const rj = document.getElementById("rejudgeBtn");
219
+ if (rj) rj.onclick = async () => {
220
+ rj.disabled = true; rj.textContent = "re-judging…";
221
+ try {
222
+ const r = await fetch("/rejudge", {
223
+ method: "POST", headers: { "content-type": "application/json" },
224
+ body: JSON.stringify({ col: colIndex, scenarioId }),
225
+ });
226
+ const body = await r.json().catch(() => ({}));
227
+ 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; }
228
+ location.reload(); // re-judge rewrote results.yaml; reload the fresh matrix + grades
229
+ } catch (e) { rj.disabled = false; rj.textContent = "Re-judge"; }
230
+ };
231
+ let t;
232
+ document.getElementById("note").addEventListener("input", (e) => {
233
+ cell.note = e.target.value;
234
+ clearTimeout(t);
235
+ t = setTimeout(() => save(col, scenarioId, cell), 500);
236
+ });
237
+ try {
238
+ const r = await fetch(`/transcript?col=${colIndex}&id=${encodeURIComponent(scenarioId)}`);
239
+ document.getElementById("transcript").textContent = r.ok ? await r.text() : "(transcript unavailable)";
240
+ } catch { document.getElementById("transcript").textContent = "(transcript unavailable)"; }
241
+ try {
242
+ const jr = await fetch(`/judge?col=${colIndex}&id=${encodeURIComponent(scenarioId)}`);
243
+ document.getElementById("judgeraw").textContent = jr.ok ? await jr.text() : "(judge output not captured)";
244
+ } catch { document.getElementById("judgeraw").textContent = "(judge output not captured)"; }
245
+ }
246
+
247
+ async function save(col, scenarioId, cell) {
248
+ const s = document.getElementById("saved");
249
+ // Optimistic: mark the cell unsaved until the server confirms. On a rejected
250
+ // save (e.g. note-less override → 400) the marker stays, so the matrix never
251
+ // shows an override as persisted when results.yaml still has it null. The
252
+ // local toggle is kept so typing a note then retriggers save.
253
+ cell._unsaved = true;
254
+ try {
255
+ const r = await fetch("/save", {
256
+ method: "POST",
257
+ headers: { "content-type": "application/json" },
258
+ body: JSON.stringify({ col: col.index, scenarioId, override: cell.override, note: cell.note }),
259
+ });
260
+ if (!r.ok) {
261
+ const body = await r.json().catch(() => ({}));
262
+ if (s) { s.textContent = body.error || "save failed"; s.classList.add("show", "err"); }
263
+ } else {
264
+ cell._unsaved = false;
265
+ if (s) { s.textContent = "saved ✓"; s.classList.remove("err"); s.classList.add("show"); setTimeout(() => s.classList.remove("show"), 1200); }
266
+ }
267
+ } catch (e) { console.error("save failed", e); }
268
+ render();
269
+ }
270
+
271
+ function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
272
+
273
+ let trendsLoaded = false;
274
+ document.getElementById("trends-toggle").onclick = async () => {
275
+ const box = document.getElementById("trends");
276
+ const btn = document.getElementById("trends-toggle");
277
+ const nowHidden = box.hasAttribute("hidden");
278
+ if (nowHidden) box.removeAttribute("hidden"); else box.setAttribute("hidden", "");
279
+ btn.textContent = (nowHidden ? "▾ Trends" : "▸ Trends");
280
+ if (nowHidden && !trendsLoaded) {
281
+ box.textContent = "loading…";
282
+ try {
283
+ const r = await fetch("/trends");
284
+ if (!r.ok) throw new Error(`trends fetch failed: ${r.status} ${await r.text().catch(() => "")}`);
285
+ renderTrends(await r.json());
286
+ trendsLoaded = true;
287
+ } catch (e) { console.error("trends load failed", e); box.textContent = "(trends unavailable)"; }
288
+ }
289
+ };
290
+
291
+ function sparkline(runs) {
292
+ if (!runs.length) return "";
293
+ const w = 8 * runs.length + 4, h = 24;
294
+ if (runs.length === 1) {
295
+ const cx = 4, cy = h - 2 - (runs[0].grade.pct / 100) * (h - 4);
296
+ return `<svg width="${w}" height="${h}" class="spark"><circle cx="${cx}" cy="${cy}" r="2" fill="currentColor"/></svg>`;
297
+ }
298
+ const pts = runs.map((r, i) => `${4 + i * 8},${h - 2 - (r.grade.pct / 100) * (h - 4)}`).join(" ");
299
+ return `<svg width="${w}" height="${h}" class="spark"><polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`;
300
+ }
301
+
302
+ function renderTrends(data) {
303
+ const box = document.getElementById("trends");
304
+ if (!data.models.length) { box.innerHTML = `<div class="empty-state">No runs yet.</div>`; return; }
305
+ const glyph = { PASS: "✓", FAIL: "✗", ERROR: "!" };
306
+ let html = "";
307
+ for (const m of data.models) {
308
+ const last = m.runs[m.runs.length - 1].grade;
309
+ const badge = last.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
310
+ const trunc = m.truncated ? ` <span class='dim'>(last ${m.runs.length})</span>` : "";
311
+ const skippedNote = m.skipped > 0 ? ` <span class='dim'>(${m.skipped} unreadable)</span>` : "";
312
+ html += `<div class="tmodel"><div class="tmodel-h">${escapeHtml(m.model)} — ${sparkline(m.runs)} ${last.letter} (${last.pct}%) ${badge}${trunc}${skippedNote}</div>`;
313
+ html += "<table class='tgrid'><thead><tr><th></th>";
314
+ for (const run of m.runs) html += `<th title="${escapeHtml(run.label || run.timestamp)}">${escapeHtml((run.label || run.timestamp).slice(0, 8))}</th>`;
315
+ html += "</tr></thead><tbody>";
316
+ for (const scn of data.scenarios) {
317
+ html += `<tr><td class='tscn'>${escapeHtml(scn.id)}</td>`;
318
+ for (const run of m.runs) {
319
+ const cell = run.cells[scn.id];
320
+ if (!cell) { html += `<td class='tc absent'>·</td>`; continue; }
321
+ const g = cell.suspect ? "?" : (glyph[cell.verdict] || "?");
322
+ const cls = escapeHtml(cell.suspect ? "suspect" : cell.verdict);
323
+ const title = `${run.label || run.timestamp}${cell.flakiness != null ? ` · flaky ${cell.flakiness.toFixed(2)}` : ""}`;
324
+ html += `<td class='tc ${cls}' title="${escapeHtml(title)}">${g}</td>`;
325
+ }
326
+ html += "</tr>";
327
+ }
328
+ html += "</tbody></table></div>";
329
+ }
330
+ box.innerHTML = html;
331
+ }
332
+
333
+ render();
334
+ </script>
335
+ </body>
336
+ </html>
package/dist/cli.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { type HarnessAdapter } from "@skill-harness/core";
3
+ export interface Args {
4
+ _: string[];
5
+ flags: Record<string, string | true>;
6
+ multi: Record<string, string[]>;
7
+ }
8
+ export declare function flagStr(args: Args, key: string, fallback?: string): string | undefined;
9
+ /** Parse the run's reps + pass-threshold flags. Throws on an invalid provided value. */
10
+ export declare function parseRunTuning(args: Args): {
11
+ reps: number;
12
+ passThreshold: number;
13
+ };
14
+ export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
15
+ /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
16
+ export declare function cmdLint(args: Args): Promise<void>;
17
+ export declare function main(argv: string[]): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync, appendFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import yaml from "js-yaml";
5
+ import { discover, resolveSkill, loadSpec, parseSpec, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, } from "@skill-harness/core";
6
+ import { getAdapter } from "@skill-harness/adapters";
7
+ import { serveReview } from "./serve.js";
8
+ const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
9
+ const DEFAULT_JUDGE = "anthropic:claude-opus-4-8";
10
+ const REPEATABLE = new Set(["model", "turn", "check"]);
11
+ function parseArgs(argv) {
12
+ const _ = [];
13
+ const flags = {};
14
+ const multi = {};
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const a = argv[i];
17
+ if (a.startsWith("--")) {
18
+ let key = a.slice(2);
19
+ let val = true;
20
+ const eq = key.indexOf("=");
21
+ if (eq >= 0) {
22
+ val = key.slice(eq + 1);
23
+ key = key.slice(0, eq);
24
+ }
25
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
26
+ val = argv[++i];
27
+ }
28
+ if (REPEATABLE.has(key)) {
29
+ (multi[key] ??= []).push(val === true ? "" : val);
30
+ }
31
+ else {
32
+ flags[key] = val;
33
+ }
34
+ }
35
+ else {
36
+ _.push(a);
37
+ }
38
+ }
39
+ return { _, flags, multi };
40
+ }
41
+ export function flagStr(args, key, fallback) {
42
+ const v = args.flags[key];
43
+ if (typeof v === "string")
44
+ return v;
45
+ if (v === true)
46
+ return "";
47
+ return fallback;
48
+ }
49
+ function resolveModels(args) {
50
+ const models = [...(args.multi.model ?? [])];
51
+ const file = flagStr(args, "models");
52
+ if (file) {
53
+ const text = readFileSync(file, "utf8");
54
+ for (const line of text.split("\n")) {
55
+ const t = line.trim();
56
+ if (t && !t.startsWith("#"))
57
+ models.push(t);
58
+ }
59
+ }
60
+ // comma-splitting within a single --model token
61
+ const expanded = models.flatMap((m) => m.split(",").map((s) => s.trim()).filter(Boolean));
62
+ return expanded.length ? expanded : [DEFAULT_MODEL];
63
+ }
64
+ function nowIso() {
65
+ return new Date().toISOString();
66
+ }
67
+ /** Parse the run's reps + pass-threshold flags. Throws on an invalid provided value. */
68
+ export function parseRunTuning(args) {
69
+ let reps = 1;
70
+ const repsRaw = flagStr(args, "reps");
71
+ if (repsRaw !== undefined && repsRaw !== "") {
72
+ const n = Number(repsRaw);
73
+ if (!Number.isInteger(n) || n < 1)
74
+ throw new Error(`--reps must be a positive integer (got \`${repsRaw}\`)`);
75
+ reps = n;
76
+ }
77
+ let passThreshold = 0.5;
78
+ const ptRaw = flagStr(args, "pass-threshold");
79
+ if (ptRaw !== undefined && ptRaw !== "") {
80
+ const t = Number(ptRaw);
81
+ if (!Number.isFinite(t) || t < 0 || t > 1)
82
+ throw new Error(`--pass-threshold must be a number in [0, 1] (got \`${ptRaw}\`)`);
83
+ passThreshold = t;
84
+ }
85
+ return { reps, passThreshold };
86
+ }
87
+ // ---------------------------------------------------------------- commands
88
+ async function cmdList(args) {
89
+ const root = flagStr(args, "skills", process.cwd());
90
+ const skills = discover(root);
91
+ console.log(`skills under ${root}:`);
92
+ for (const s of skills) {
93
+ if (!s.hasSpec) {
94
+ console.log(` ○ ${s.name} (no spec)`);
95
+ continue;
96
+ }
97
+ try {
98
+ const spec = loadSpec(s.specPath);
99
+ const seeded = spec.scenarios.filter((x) => x.mode === "seeded").length;
100
+ const seededNote = seeded ? `, ${seeded} seeded` : "";
101
+ console.log(` ● ${s.name} (${spec.scenarios.length} scenarios${seededNote})`);
102
+ }
103
+ catch (e) {
104
+ console.log(` ✗ ${s.name} INVALID: ${e instanceof Error ? e.message : e}`);
105
+ }
106
+ }
107
+ console.log(`\n● = testable · ○ = no spec yet · ✗ = spec present but invalid`);
108
+ }
109
+ async function cmdRun(args) {
110
+ const root = flagStr(args, "skills", process.cwd());
111
+ const target = args._[0];
112
+ if (!target)
113
+ throw new Error("usage: skill-harness run <skill|all> --skills <root>");
114
+ const harnessName = flagStr(args, "harness", "pi");
115
+ const adapter = getAdapter(harnessName);
116
+ if (!(await adapter.available()))
117
+ throw new Error(`harness \`${harnessName}\` is not on PATH`);
118
+ const mode = flagStr(args, "mode", "green") || "green";
119
+ const judge = parseModelRef(flagStr(args, "judge", DEFAULT_JUDGE));
120
+ const label = flagStr(args, "label") || null;
121
+ const parallel = Math.max(1, Number(flagStr(args, "parallel", "1")) || 1);
122
+ const { reps, passThreshold } = parseRunTuning(args);
123
+ const modelTokens = resolveModels(args);
124
+ const skills = target === "all"
125
+ ? discover(root).filter((s) => s.hasSpec)
126
+ : [resolveSkill(root, target)];
127
+ const summaries = [];
128
+ for (const skill of skills) {
129
+ if (!skill.hasSpec) {
130
+ console.log(`skip ${skill.name}: no spec`);
131
+ continue;
132
+ }
133
+ const spec = loadSpec(skill.specPath);
134
+ for (const token of modelTokens) {
135
+ const model = parseModelRef(token);
136
+ console.log(`\n▶ ${spec.skill} · ${harnessName}:${token} · mode=${mode} · judge=${judge.provider}:${judge.model}`);
137
+ const summary = await runSkillModel({
138
+ spec,
139
+ skillDir: skill.dir,
140
+ specPath: skill.specPath,
141
+ adapter,
142
+ model,
143
+ modelToken: token,
144
+ judge,
145
+ mode,
146
+ timestamp: nowIso(),
147
+ label,
148
+ concurrency: parallel,
149
+ reps,
150
+ passThreshold,
151
+ onProgress: (m) => console.log(m),
152
+ });
153
+ summaries.push(summary);
154
+ console.log("\n" + formatScorecard(summary) + "\n");
155
+ }
156
+ }
157
+ console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
158
+ }
159
+ export async function cmdGrade(args, adapterOverride) {
160
+ const runDir = args._[0];
161
+ if (!runDir || !existsSync(runDir))
162
+ throw new Error("usage: skill-harness grade <run-dir> [--judge prov:model]");
163
+ // spec lives at <runDir>/../../../specification.yaml (results/<tag>/<ts> -> tests/)
164
+ const testsDir = dirname(dirname(dirname(runDir)));
165
+ const specPath = join(testsDir, "specification.yaml");
166
+ const spec = loadSpec(specPath);
167
+ const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
168
+ // Re-judge with the run's RECORDED judge + harness (parity with /rejudge) —
169
+ // an explicit --judge flag still wins; with no prior results, fall back to
170
+ // the CLI default.
171
+ const judgeFlag = flagStr(args, "judge");
172
+ const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev?.judge ?? parseModelRef(DEFAULT_JUDGE));
173
+ const adapter = adapterOverride ?? getAdapter(prev?.harness ?? "pi");
174
+ const results = await regradeRun({ runDir, spec, adapter, judge, specDir: testsDir, now: nowIso });
175
+ for (const s of results.scenarios) {
176
+ console.log(` ${s.id} → ${s.judge_verdict}: ${s.judge_reason}`);
177
+ }
178
+ const g = results.effective_grade;
179
+ console.log(`\n re-graded with ${judge.provider}:${judge.model} → ${g.letter} (${g.pct}%) ${g.ship ? "SHIP" : "NOT READY"}`);
180
+ }
181
+ async function cmdReview(args) {
182
+ const root = flagStr(args, "skills", process.cwd());
183
+ const target = args._[0];
184
+ if (!target)
185
+ throw new Error("usage: skill-harness review <skill> --skills <root>");
186
+ const skill = resolveSkill(root, target);
187
+ const port = Number(flagStr(args, "port", "0")) || 0;
188
+ await serveReview({ skillDir: skill.dir, skillName: skill.name, port });
189
+ }
190
+ async function cmdAddTest(args) {
191
+ const root = flagStr(args, "skills", process.cwd());
192
+ const target = args._[0];
193
+ if (!target)
194
+ throw new Error("usage: skill-harness add-test <skill> --skills <root> --id ... --title ... --turn ... --check ...");
195
+ const skill = resolveSkill(root, target);
196
+ if (!skill.hasSpec)
197
+ throw new Error(`${target} has no spec yet — create tests/specification.yaml first`);
198
+ const id = flagStr(args, "id");
199
+ const title = flagStr(args, "title");
200
+ const turns = args.multi.turn ?? [];
201
+ const checks = args.multi.check ?? [];
202
+ if (!id || !title || turns.length === 0 || checks.length === 0) {
203
+ throw new Error("add-test requires --id, --title, at least one --turn and one --check");
204
+ }
205
+ // Validate the merged spec before writing.
206
+ const existing = loadSpec(skill.specPath);
207
+ if (existing.scenarios.some((s) => s.id === id))
208
+ throw new Error(`scenario id \`${id}\` already exists`);
209
+ const scenario = { id, title };
210
+ if (flagStr(args, "critical") !== undefined)
211
+ scenario.critical = true;
212
+ const mode = flagStr(args, "mode");
213
+ if (mode === "seeded") {
214
+ scenario.mode = "seeded";
215
+ scenario.fixture = flagStr(args, "fixture") ?? `fixtures/${id}`;
216
+ }
217
+ scenario.turns = turns;
218
+ scenario.checklist = checks;
219
+ const block = "\n" + yaml.dump({ scenarios: [scenario] }).replace(/^scenarios:\n/, "");
220
+ const merged = readFileSync(skill.specPath, "utf8") + block;
221
+ parseSpec(merged, skill.specPath); // throws if the append broke the spec
222
+ appendFileSync(skill.specPath, block, "utf8");
223
+ console.log(`added scenario ${id} to ${skill.specPath}`);
224
+ }
225
+ /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
226
+ export async function cmdLint(args) {
227
+ const root = flagStr(args, "skills", process.cwd());
228
+ const target = args._[0] ?? "all";
229
+ let skillDirs;
230
+ try {
231
+ skillDirs = target === "all"
232
+ ? discover(root).filter((s) => s.hasSpec).map((s) => s.dir)
233
+ : [resolveSkill(root, target).dir];
234
+ }
235
+ catch (e) {
236
+ console.error(`error: ${e instanceof Error ? e.message : e}`);
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+ if (skillDirs.length === 0) {
241
+ console.error(`no skills with a spec under ${root}`);
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+ const gha = process.env.GITHUB_ACTIONS === "true";
246
+ const findings = [];
247
+ for (const dir of skillDirs) {
248
+ let f;
249
+ try {
250
+ f = lintSkill(dir);
251
+ }
252
+ catch (e) {
253
+ f = [{ skill: dir, code: "lint-error", message: e instanceof Error ? e.message : String(e) }];
254
+ }
255
+ findings.push(...f);
256
+ if (f.length === 0)
257
+ console.log(`✓ ${dir}`);
258
+ else
259
+ for (const x of f) {
260
+ const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
261
+ console.log(`✗ ${where}: ${x.code} — ${x.message}`);
262
+ if (gha)
263
+ console.log(`::error title=skill-harness::${where}: ${x.code} — ${x.message}`);
264
+ }
265
+ }
266
+ console.log(`\n${skillDirs.length} skill(s), ${findings.length} finding(s)`);
267
+ process.exitCode = findings.length > 0 ? 1 : 0;
268
+ }
269
+ // ---------------------------------------------------------------- dispatch
270
+ const HELP = `skill-harness — test/optimize loop for agent skills (pi harness)
271
+
272
+ run <skill|all> --skills <root> [--model prov:model ...] [--models file]
273
+ [--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
274
+ grade <run-dir> [--judge prov:model] re-grade saved transcripts (neutral judge)
275
+ review <skill> --skills <root> [--port N] serve the interactive review UI
276
+ add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
277
+ list --skills <root> discovered skills + spec status
278
+ lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (CI gate; exits non-zero on findings)
279
+
280
+ defaults: model=${DEFAULT_MODEL} judge=${DEFAULT_JUDGE} mode=green harness=pi`;
281
+ export async function main(argv) {
282
+ const cmd = argv[0];
283
+ const args = parseArgs(argv.slice(1));
284
+ switch (cmd) {
285
+ case "run": return cmdRun(args);
286
+ case "grade": return cmdGrade(args);
287
+ case "review": return cmdReview(args);
288
+ case "add-test": return cmdAddTest(args);
289
+ case "list": return cmdList(args);
290
+ case "lint": return cmdLint(args);
291
+ case undefined:
292
+ case "help":
293
+ case "--help":
294
+ case "-h":
295
+ console.log(HELP);
296
+ return;
297
+ default:
298
+ console.error(`unknown command: ${cmd}\n`);
299
+ console.log(HELP);
300
+ process.exitCode = 1;
301
+ }
302
+ }
303
+ // Skip dispatch under vitest: importing this module (e.g. to exercise cmdGrade
304
+ // directly in tests) must not also run a CLI command against the test runner's
305
+ // own argv. Real entrypoints (tsx on src/cli.ts, or the bin launcher importing
306
+ // dist/cli.js) never set VITEST, so this leaves production invocation untouched.
307
+ if (!process.env.VITEST) {
308
+ main(process.argv.slice(2)).catch((e) => {
309
+ console.error(`error: ${e instanceof Error ? e.message : e}`);
310
+ process.exitCode = 1;
311
+ });
312
+ }
313
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,14 @@
1
+ export interface ServeOptions {
2
+ skillDir: string;
3
+ skillName: string;
4
+ port?: number;
5
+ open?: boolean;
6
+ adapter?: import("@skill-harness/core").HarnessAdapter;
7
+ assetsDir?: string;
8
+ }
9
+ export interface ServeHandle {
10
+ port: number;
11
+ close: () => void;
12
+ }
13
+ export declare function serveReview(opts: ServeOptions): Promise<ServeHandle>;
14
+ export declare function tryOpen(url: string, cmd?: string): void;
package/dist/serve.js ADDED
@@ -0,0 +1,222 @@
1
+ import { createServer } from "node:http";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+ import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, findJudgeRawFiles, effectiveThreshold, } from "@skill-harness/core";
7
+ import { getAdapter } from "@skill-harness/adapters";
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ /** Locate assets/report.template.html relative to dist/ or src/. */
10
+ function templatePath(assetsDir) {
11
+ if (assetsDir)
12
+ return join(assetsDir, "report.template.html");
13
+ const candidates = [
14
+ join(__dirname, "..", "..", "..", "assets", "report.template.html"), // packages/cli/{dist,src} -> ../../../assets
15
+ join(__dirname, "..", "assets", "report.template.html"),
16
+ join(__dirname, "..", "..", "assets", "report.template.html"),
17
+ ];
18
+ for (const c of candidates)
19
+ if (existsSync(c))
20
+ return c;
21
+ throw new Error("cannot find assets/report.template.html");
22
+ }
23
+ /** assets/report.grade.js — the client scorer injected into the template (sibling of the template). */
24
+ function gradeScriptPath(assetsDir) {
25
+ return join(dirname(templatePath(assetsDir)), "report.grade.js");
26
+ }
27
+ function readBody(req) {
28
+ return new Promise((resolve) => {
29
+ let b = "";
30
+ req.on("data", (c) => (b += c));
31
+ req.on("end", () => resolve(b));
32
+ });
33
+ }
34
+ /** All of a scenario's transcripts, concatenated with a filename header per file for reps runs. */
35
+ function findTranscript(runDir, id) {
36
+ const files = findTranscriptFiles(runDir, id);
37
+ if (files.length === 0)
38
+ return null;
39
+ if (files.length === 1)
40
+ return readFileSync(join(runDir, files[0]), "utf8");
41
+ return files.map((f) => `===== ${f} =====\n${readFileSync(join(runDir, f), "utf8")}`).join("\n\n");
42
+ }
43
+ /** All of a scenario's judge-raw artifacts, concatenated with a header per rep. */
44
+ function findJudgeRaw(runDir, id) {
45
+ // Mode-agnostic (no mode arg): run.ts writes judge-raw for every mode
46
+ // (red/force too), and /transcript's findTranscript is mode-agnostic —
47
+ // the inspector must show a red/force run's judge output too.
48
+ const files = findJudgeRawFiles(runDir, id);
49
+ if (files.length === 0)
50
+ return null;
51
+ if (files.length === 1)
52
+ return readFileSync(join(runDir, files[0]), "utf8");
53
+ return files.map((f) => `===== ${f} =====\n${readFileSync(join(runDir, f), "utf8")}`).join("\n\n");
54
+ }
55
+ export async function serveReview(opts) {
56
+ const template = readFileSync(templatePath(opts.assetsDir), "utf8");
57
+ const gradeScript = readFileSync(gradeScriptPath(opts.assetsDir), "utf8");
58
+ const server = createServer(async (req, res) => {
59
+ try {
60
+ const url = new URL(req.url ?? "/", "http://localhost");
61
+ if (req.method === "GET" && url.pathname === "/") {
62
+ const data = collectReport(opts.skillDir);
63
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
64
+ res.end(renderReport(template, data, gradeScript));
65
+ return;
66
+ }
67
+ if (req.method === "GET" && url.pathname === "/transcript") {
68
+ const col = Number(url.searchParams.get("col"));
69
+ const id = url.searchParams.get("id") ?? "";
70
+ const data = collectReport(opts.skillDir);
71
+ const column = data.columns.find((c) => c.index === col);
72
+ const text = column ? findTranscript(column.runDir, id) : null;
73
+ res.writeHead(text ? 200 : 404, { "content-type": "text/plain; charset=utf-8" });
74
+ res.end(text ?? "transcript not found");
75
+ return;
76
+ }
77
+ if (req.method === "GET" && url.pathname === "/judge") {
78
+ const col = Number(url.searchParams.get("col"));
79
+ const id = url.searchParams.get("id") ?? "";
80
+ const data = collectReport(opts.skillDir);
81
+ const column = data.columns.find((c) => c.index === col);
82
+ const text = column ? findJudgeRaw(column.runDir, id) : null;
83
+ res.writeHead(text ? 200 : 404, { "content-type": "text/plain; charset=utf-8" });
84
+ res.end(text ?? "judge output not captured");
85
+ return;
86
+ }
87
+ if (req.method === "GET" && url.pathname === "/trends") {
88
+ const data = collectTrends(opts.skillDir);
89
+ res.writeHead(200, { "content-type": "application/json" });
90
+ res.end(JSON.stringify(data));
91
+ return;
92
+ }
93
+ if (req.method === "POST" && url.pathname === "/rejudge") {
94
+ const body = JSON.parse((await readBody(req)) || "{}");
95
+ const data = collectReport(opts.skillDir);
96
+ const column = data.columns.find((c) => c.index === body.col);
97
+ if (!column) {
98
+ res.writeHead(404).end("unknown column");
99
+ return;
100
+ }
101
+ const results = readResults(column.runDir);
102
+ if (results.mode !== "green") {
103
+ res.writeHead(400, { "content-type": "application/json" });
104
+ res.end(JSON.stringify({ ok: false, error: "only green runs can be re-judged" }));
105
+ return;
106
+ }
107
+ const specPath = join(opts.skillDir, "tests", "specification.yaml");
108
+ const spec = loadSpec(specPath);
109
+ const scenario = spec.scenarios.find((s) => s.id === body.scenarioId);
110
+ if (!scenario) {
111
+ res.writeHead(404).end("unknown scenario");
112
+ return;
113
+ }
114
+ const adapter = opts.adapter ?? getAdapter(results.harness);
115
+ if (!(await adapter.available())) {
116
+ res.writeHead(400, { "content-type": "application/json" });
117
+ res.end(JSON.stringify({ ok: false, error: `harness \`${results.harness}\` is not on PATH` }));
118
+ return;
119
+ }
120
+ const prev = results.scenarios.find((s) => s.id === body.scenarioId);
121
+ if (!prev) {
122
+ res.writeHead(404).end("scenario not in this run");
123
+ return;
124
+ }
125
+ const threshold = effectiveThreshold(prev, scenario);
126
+ try {
127
+ const rr = await regradeScenario({
128
+ runDir: column.runDir, spec, scenario, adapter, judge: results.judge,
129
+ specDir: dirname(specPath), threshold,
130
+ });
131
+ const merged = results.scenarios.map((s) => s.id === body.scenarioId ? { ...rr, override: s.override, note: s.note } : s);
132
+ const written = writeResults(column.runDir, {
133
+ skill: results.skill, harness: results.harness, model: results.model, judge: results.judge,
134
+ timestamp: results.timestamp, label: results.label, mode: results.mode, scenarios: merged,
135
+ }, { shipBar: spec.ship_bar, critical: spec.critical });
136
+ ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
137
+ const g = written.effective_grade;
138
+ appendJournal(column.runDir, { event: "score", ts: new Date().toISOString(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
139
+ res.writeHead(200, { "content-type": "application/json" });
140
+ res.end(JSON.stringify({ ok: true, grade: g }));
141
+ }
142
+ catch (e) {
143
+ // regradeScenario (or the write/journal that follows) failed — surface the
144
+ // real reason as JSON so the client's r.json().catch(()=>({})) sees body.error
145
+ // instead of falling through to the generic top-level 500 (text/plain).
146
+ res.writeHead(400, { "content-type": "application/json" });
147
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
148
+ }
149
+ return;
150
+ }
151
+ if (req.method === "POST" && url.pathname === "/save") {
152
+ const body = JSON.parse((await readBody(req)) || "{}");
153
+ const data = collectReport(opts.skillDir);
154
+ const column = data.columns.find((c) => c.index === body.col);
155
+ if (!column) {
156
+ res.writeHead(404).end("unknown column");
157
+ return;
158
+ }
159
+ const results = readResults(column.runDir);
160
+ let patched;
161
+ try {
162
+ patched = applyOverride(results, body.scenarioId, body.override ?? null, body.note ?? "");
163
+ }
164
+ catch (e) {
165
+ res.writeHead(400, { "content-type": "application/json" });
166
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
167
+ return;
168
+ }
169
+ // writeResults recomputes effective_grade override-aware against the CURRENT
170
+ // spec's ship bar — a saved override can never leave a stale grade. Only
171
+ // green runs are scored (PR #1 finding: /save must not grade red/force runs).
172
+ const spec = loadSpec(join(opts.skillDir, "tests", "specification.yaml"));
173
+ const ctx = patched.mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
174
+ writeResults(column.runDir, patched, ctx);
175
+ // Unconditional: a results root created before schema-2/journal.jsonl existed
176
+ // may still have a stale .gitignore body — every save (not just overrides)
177
+ // must roll it forward so journal.jsonl doesn't end up tracked.
178
+ ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
179
+ if (body.override != null) {
180
+ preserveTranscript(join(opts.skillDir, "tests", "results"), column.runDir, body.scenarioId);
181
+ }
182
+ appendJournal(column.runDir, {
183
+ event: "override", ts: new Date().toISOString(),
184
+ id: body.scenarioId, override: body.override ?? null, note: body.note ?? "",
185
+ });
186
+ res.writeHead(200, { "content-type": "application/json" });
187
+ res.end(JSON.stringify({ ok: true }));
188
+ return;
189
+ }
190
+ res.writeHead(404).end("not found");
191
+ }
192
+ catch (e) {
193
+ res.writeHead(500, { "content-type": "text/plain" });
194
+ res.end(`server error: ${e instanceof Error ? e.message : e}`);
195
+ }
196
+ });
197
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
198
+ const addr = server.address();
199
+ const port = typeof addr === "object" && addr ? addr.port : opts.port;
200
+ const link = `http://127.0.0.1:${port}/`;
201
+ console.log(`\n skill-harness review · ${opts.skillName}`);
202
+ console.log(` → ${link}`);
203
+ console.log(` flip verdicts + add notes in the browser; saves persist to results.yaml.`);
204
+ console.log(` Ctrl-C to stop.\n`);
205
+ if (opts.open !== false && !process.env.SKILL_CHECK_NO_OPEN)
206
+ tryOpen(link);
207
+ return { port: port, close: () => server.close() };
208
+ }
209
+ export function tryOpen(url, cmd) {
210
+ const opener = cmd ?? (process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open");
211
+ try {
212
+ const child = spawn(opener, [url], { stdio: "ignore", detached: true });
213
+ // spawn emits 'error' asynchronously (e.g. xdg-open ENOENT in headless envs);
214
+ // an unhandled 'error' event would crash the process — swallow it.
215
+ child.on("error", () => { });
216
+ child.unref();
217
+ }
218
+ catch {
219
+ /* best effort */
220
+ }
221
+ }
222
+ //# sourceMappingURL=serve.js.map
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@skill-harness/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "main": "./dist/cli.js",
7
+ "bin": {
8
+ "skill-harness": "dist/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/cli.js",
12
+ "./serve": "./dist/serve.js"
13
+ },
14
+ "files": [
15
+ "dist/**/*.js",
16
+ "dist/**/*.d.ts",
17
+ "assets",
18
+ "LICENSE"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/mojomanyana/skill-harness.git"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "prepack": "rm -rf ./assets && cp -r ../../assets ./assets && cp ../../LICENSE ./LICENSE"
32
+ },
33
+ "dependencies": {
34
+ "@skill-harness/core": "0.1.0",
35
+ "@skill-harness/adapters": "0.1.0"
36
+ }
37
+ }