Setting the file. One moment.
Build Report Lite · Claude API · anthropics/skills · Skills Docs
ContentsBack to the top of the page — line 142
This file
Number 5.46
Position 46 of 94
Type JavaScript
Size 27 KB
Lines 488 shared/evals/report/ build-report-lite.mjs
JavaScript · 488 lines · 27 KB
//
15 // What it deliberately does not do: render markdown, inline transcripts or
16 // attachments, or execute anything from the data. Every string from disk is
17 // HTML-escaped; traces are linked by relative path, not embedded. Sorting is
18 // a few lines of inline script over the rendered table only.
19 //
20 // Runs under node or bun. Node builtins only.
21
22 import { closeSync, constants as FS, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs' ;
23 import { dirname, isAbsolute, join, resolve } from 'node:path' ;
24
25 const isNum = x => typeof x === 'number' && Number. isFinite (x);
26 // readText/readJSON refuse symlinks, like the full builder's lib/adapter.mjs:
27 // the flow dir is model-influenced, and a prompt-injected agent could plant
28 // `summary.json -> ~/.ssh/id_rsa`; the user later runs this builder (outside
29 // any sandbox) and the target lands in the shareable report.html. Open
30 // O_NOFOLLOW and fstat the fd (not lstat-then-read) so a concurrent writer
31 // can't swap in a symlink between the check and the read.
32 // O_NOFOLLOW is only trustworthy on POSIX: Node leaves it undefined on
33 // Windows and Bun defines it there with a meaningless nonzero value that
34 // makes every open fail, so the flag is applied by platform, and
35 // Windows falls back to an lstat refusal - a check-then-use window, but the
36 // alternative is no symlink check at all.
37 const WIN = process.platform === 'win32' ;
38 const NOFOLLOW = WIN ? 0 : FS . O_NOFOLLOW ;
39 // A guard that cannot tell must refuse: only "no such entry" reads as absent;
40 // any other lstat failure (EACCES, ENAMETOOLONG, ...) is rethrown, never "no".
41 const isSymlink = p => {
42 try { return lstatSync (p). isSymbolicLink (); } catch (e) { if (e?.code === 'ENOENT' ) return false ; throw e; }
43 };
44 // Files skipped for having a second hard link, so build() can say so as well
45 // as reporting them as missing or empty.
46 const skippedHardLinks = [];
47 const readText = p => {
48 let fd;
49 try {
50 if ( WIN && isSymlink (p)) return '' ;
51 fd = openSync (p, FS . O_RDONLY | NOFOLLOW );
52 const st = fstatSync (fd);
53 // O_NOFOLLOW and lstat cannot see a hard link (a second name for a file
54 // outside the flow dir); nothing in a flow dir needs one, so read nothing.
55 if ( ! st. isFile ()) return '' ;
56 if (st.nlink > 1 ) { skippedHardLinks. push (p); return '' ; }
57 return readFileSync (fd, 'utf8' );
58 } catch { return '' ; }
59 finally { if (fd !== undefined ) closeSync (fd); }
60 };
61 // Output writes get the same discipline as reads: the flow dir is
62 // model-influenced, so `report.html -> ~/.bashrc` planted there must not be
63 // followed. POSIX opens O_NOFOLLOW (a symlink fails with ELOOP); Windows
64 // lstat-refuses first. A symlinked parent dir is refused the same way.
65 // The immediate-parent lstat can't see a symlink higher up the path, so
66 // writes are also bound to the flow root resolved ONCE at build entry
67 // (resolveOutputRoot below).
68 // Re-resolving the caller-named path here would self-anchor: root and
69 // target would resolve through the same planted link and the check would
70 // pass vacuously.
71 function assertContained ( dir , root ) {
72 if (root == null ) throw new Error ( 'refusing to write: output root not resolved' );
73 const dirReal = realpathSync (dir);
74 if (dirReal !== root && ! dirReal. startsWith (root + ( WIN ? ' \\ ' : '/' )))
75 throw new Error ( 'refusing to write outside ' + root + ': ' + dir);
76 }
77 // Refuse a symlink at the flow path itself, then pin its resolution for the
78 // whole build. Takes the path AS THE CALLER NAMED IT: a RELATIVE root is
79 // lstat-walked component by component from the cwd first (a pre-planted
80 // ancestor link would otherwise relocate the anchor itself), and a trailing
81 // separator is stripped (lstat("link/") follows the final symlink).
82 // Residual: an ancestor link above an ABSOLUTE root stays the caller's own
83 // trust decision (it can be legitimate - macOS /tmp); a swap after entry
84 // fails assertContained when the check sees it. Residual, all platforms: the
85 // check and the open are separate path lookups (Node's sync fs has no
86 // openat-style call), so a directory swapped for a symlink in between is
87 // still followed. This stops a planted link, not a writer racing the build.
88 function resolveOutputRoot ( root ) {
89 // Only Windows treats `\` as a separator; on POSIX it is a filename byte, so
90 // splitting on it would walk prefixes that are not real path components.
91 root = String (root). replace ( WIN ? /( . ) [ \\ /] +$ / : /( . ) \/ +$ / , '$1' );
92 const segments = root. split ( WIN ? / [ \\ /] / : '/' );
93 // A `.`/`..` segment (e.g. a trailing `/.`) makes the leaf lstat below
94 // follow a planted link at the flow root, and the absolute branch skips the
95 // ancestor walk. Refuse dot segments outright.
96 if (segments. some ( seg => seg === '.' || seg === '..' ))
97 throw new Error ( 'refusing to write into ' + root + ": '.'/'..' path segment" );
98 if ( ! isAbsolute (root)) {
99 let walk = '' ;
100 for ( const part of segments. filter (Boolean). slice ( 0 , - 1 )) {
101 walk = walk ? join (walk, part) : part;
102 if ( isSymlink (walk)) throw new Error ( 'refusing to write into ' + root + ': ancestor ' + walk + ' is a symlink' );
103 }
104 }
105 if ( isSymlink (root)) throw new Error ( 'refusing to write into ' + root + ': it is a symlink' );
106 return realpathSync (root);
107 }
108 function writeNoFollow ( p , data , root ) {
109 if ( isSymlink ( dirname (p))) throw new Error ( 'refusing to write through symlinked directory: ' + dirname (p));
110 assertContained ( dirname (p), root);
111 if ( WIN && isSymlink (p)) throw new Error ( 'refusing to write through symlink: ' + p);
112 // Opened without O_TRUNC and truncated only after the checks, so a refused
113 // file keeps its bytes.
114 const fd = openSync (p, FS . O_WRONLY | FS . O_CREAT | NOFOLLOW , 0o644 );
115 try {
116 const st = fstatSync (fd);
117 if ( ! st. isFile ()) throw new Error ( 'refusing to write to non-regular file: ' + p);
118 if (st.nlink > 1 ) throw new Error ( 'refusing to write to ' + p + ': it has a second hard link (another name for the same file); replace it with a plain copy if it is yours' );
119 ftruncateSync (fd, 0 );
120 // writeFileSync on the fd loops until every byte lands (a bare writeSync
121 // is one write(2) that may return short on ENOSPC and silently truncate).
122 writeFileSync (fd, data);
123 } finally { closeSync (fd); }
124 }
125 function mkdirNoFollow ( dir , root ) {
126 if ( isSymlink (dir)) throw new Error ( 'refusing to use symlinked directory: ' + dir);
127 mkdirSync (dir, { recursive: true });
128 // Check after creating: mkdirSync(recursive) follows symlinked ancestors.
129 assertContained (dir, root);
130 }
131 const readJSON = ( p , dflt ) => { try { return JSON . parse ( readText (p)); } catch { return dflt; } };
132 // Warnings/errors interpolate model-influenced bytes (directory names,
133 // JSON.parse messages). Strip escape sequences and control characters before
134 // they reach the terminal, so a planted
135 // OSC/CSI can't retitle the terminal or forge output lines.
136 const ESC_SEQ = / \x1b \[ [0-?] * [ - \/ ] * [@-~] |\x1b \] [ ^\x07\x1b ] * (?: \x07|\x1b \\ ) ?|\x1b [@-_] / g ;
137 const CONTROL = / [ \x00 - \x1f\x7f - \x9f ] / g ;
138 const termSafe = s => String (s). replace ( ESC_SEQ , '' ). replace ( CONTROL , '' );
139 const eprint = ( ... a ) => console. error ( ... a. map (termSafe));
140 const esc = s => String (s ?? '' ). replace ( / [&<>"'] / g , c =>
141 ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[c]));
142 const fmt = x => (x == null ? '' : x. toFixed ( 3 ));
143 // TSV id cells come from untrusted results.jsonl: strip the separators that
144 // would forge rows/columns in trajectory/scores.tsv, and neutralize a leading
145 // formula trigger so spreadsheet apps don't execute '=WEBSERVICE(...)' on
146 // open. Mirrored in the full builder's lib/adapter.mjs so both emit
147 // byte-identical scores.tsv.
148 const tsvCell = s => {
149 const flat = String (s). replace ( / [\t\n\r] / g , ' ' );
150 return / ^ [=+\-@] / . test (flat) ? "'" + flat : flat;
151 };
152
153 // Variant directories: exactly `baseline` or `v<N>`, numeric order.
154 function discoverVariants ( flow ) {
155 const names = readdirSync (flow, { withFileTypes: true })
156 . filter ( e => e. isDirectory () && / ^ (baseline | v \d + ) $ / . test (e.name))
157 . map ( e => e.name);
158 const rank = n => (n === 'baseline' ? - 1 : + n. slice ( 1 ));
159 return names. sort (( a , b ) => rank (a) - rank (b));
160 }
161
162 // Mirrors the full adapter: an object of numeric/boolean grades, a bare
163 // boolean, or a bare number (-> {score}).
164 function coerceScores ( g ) {
165 if (g && typeof g === 'object' && ! Array. isArray (g)) {
166 const out = {};
167 for ( const [ k , v ] of Object. entries (g)) if ( isNum (v) || typeof v === 'boolean' ) out[k] = + v;
168 return out;
169 }
170 if ( typeof g === 'boolean' ) return { score: g ? 1 : 0 };
171 if ( isNum (g)) return { score: g };
172 return {};
173 }
174
175 function loadRows ( p , warnings , dir ) {
176 // Keyed by data-supplied ids: null-prototype so '__proto__' is a key, not a crash.
177 const byId = Object. create ( null );
178 readText (p). split ( ' \n ' ). forEach (( line , i ) => {
179 line = line. trim ();
180 if ( ! line) return ;
181 let r;
182 try { r = JSON . parse (line); } catch { warnings. push (dir + '/results.jsonl:' + (i + 1 ) + ': malformed JSON, skipped' ); return ; }
183 const pid = String (r.prompt_id ?? r.id ?? r.case_id ?? '' );
184 if ( ! pid) { warnings. push (dir + '/results.jsonl:' + (i + 1 ) + ': no prompt_id, skipped' ); return ; }
185 (byId[pid] ||= []). push (r);
186 });
187 return byId;
188 }
189
190 function countErrors ( vdir ) {
191 let total = 0 ;
192 const byClass = Object. create ( null );
193 for ( const line of readText ( join (vdir, 'errors.jsonl' )). split ( ' \n ' )) {
194 if ( ! line. trim ()) continue ;
195 let e; try { e = JSON . parse (line); } catch { continue ; }
196 total ++ ;
197 const c = String (e?.failure_class ?? 'error' );
198 byClass[c] = (byClass[c] || 0 ) + 1 ;
199 }
200 return { total, byClass };
201 }
202
203 // Inferred metrics, mirroring the full builder's inferMetrics: any
204 // explanation for a key -> judge, all-0/1 values -> binary, else float;
205 // first-seen order.
206 function inferMetrics ( rows , variants ) {
207 const seen = new Map ();
208 for ( const v of variants) for ( const reps of Object. values (rows[v])) for ( const r of reps) {
209 const expl = r.explanation && typeof r.explanation === 'object' ? r.explanation : {};
210 for ( const [ k , val ] of Object. entries ( coerceScores (r.grade))) {
211 const m = seen. get (k) || { binary: true , judge: false };
212 if (val !== 0 && val !== 1 ) m.binary = false ;
213 if (k in expl) m.judge = true ;
214 seen. set (k, m);
215 }
216 }
217 return [ ... seen. entries ()]. map (([ id , s ]) => ({ id, kind: s.judge ? 'judge' : s.binary ? 'binary' : 'float' }));
218 }
219
220 function build ( flowArg ) {
221 const flow = resolve (flowArg);
222 if ( ! statSync (flow, { throwIfNoEntry: false })?. isDirectory ()) throw new Error ( 'not a directory: ' + flow);
223 // Pinned from the path as the caller named it, so the relative-ancestor
224 // walk in resolveOutputRoot applies (resolve() would skip it).
225 const flowRoot = resolveOutputRoot (flowArg);
226 const variants = discoverVariants (flow);
227 if ( ! variants. length ) throw new Error ( 'no variant directories (baseline/, v1/, ...) under ' + flow);
228 const warnings = [];
229 // Same warning the full builder gives: a mis-named variant dir is the usual
230 // reason the header shows one variant fewer than expected.
231 const nonVariant = new Set ([ 'trajectory' , 'attachments' , 'refs' , 'ref' , 'inputs' , 'out' , 'target' , '__pycache__' ]);
232 for ( const e of readdirSync (flow, { withFileTypes: true }))
233 if (e. isDirectory () && ! variants. includes (e.name) && ! nonVariant. has (e.name)
234 && ! e.name. startsWith ( '.' ) && ! e.name. startsWith ( '_' ))
235 warnings. push ( "ignored directory '" + e.name + "/' - variant dirs must be named 'baseline' or 'v<N>'; put the descriptive name in change.md's first line instead" );
236 const state = readJSON ( join (flow, '_state.json' ), {}) || {};
237
238 const splitOf = Object. create ( null );
239 for ( const sp of [ 'train' , 'val' , 'test' ]) {
240 const ids = state[sp + '_ids' ];
241 if (ids == null ) continue ;
242 if ( ! Array. isArray (ids)) { warnings. push ( '_state.json ' + sp + '_ids is not a list - ignored' ); continue ; }
243 for ( const pid of ids) splitOf[ String (pid)] = sp;
244 }
245
246 const rows = Object. create ( null );
247 for ( const v of variants) rows[v] = loadRows ( join (flow, v, 'results.jsonl' ), warnings, v);
248
249 // Trace links are emitted only for a real traces/ directory that resolves
250 // inside the pinned flow root: the per-file lstat below refuses a symlinked
251 // trace but silently resolves a symlinked traces/ (or variant) directory,
252 // which would link files from wherever it points into the report.
253 const traceDirOk = Object. create ( null );
254 for ( const v of variants) {
255 const tdir = join (flow, v, 'traces' );
256 try {
257 if ( isSymlink ( join (flow, v)) || isSymlink (tdir)) {
258 warnings. push (v + '/traces is reached through a symlink - trace links omitted' );
259 traceDirOk[v] = false ;
260 } else {
261 const real = realpathSync (tdir);
262 traceDirOk[v] = real. startsWith (flowRoot + ( WIN ? ' \\ ' : '/' ));
263 }
264 } catch { traceDirOk[v] = false ; }
265 }
266
267 // Same as the full builder: drop non-baseline variants with zero result
268 // rows (a vN/ holding only change.md while the run warms up would render a
269 // blank column), and refuse to build when the first variant itself has none
270 // rather than overwrite a good trajectory/scores.tsv with a header-only file.
271 for ( let i = variants. length - 1 ; i > 0 ; i -- ) {
272 const v = variants[i];
273 if (Object. keys (rows[v]). length ) continue ;
274 warnings. push (v + ': zero result rows - dropping (run not started or results.jsonl missing/empty)' );
275 variants. splice (i, 1 );
276 }
277 if ( ! Object. keys (rows[variants[ 0 ]]). length )
278 throw new Error (variants[ 0 ] + '/ has no result rows under ' + flow + ' - nothing to report'
279 + (skippedHardLinks. length ? ' (not read, second hard link: ' + skippedHardLinks. map ( p => p. slice (flow. length + 1 )). join ( ', ' ) + ' - replace with plain copies if they are yours)' : '' ));
280
281 const header = Object. create ( null );
282 for ( const v of variants) {
283 const vdir = join (flow, v);
284 const change = readText ( join (vdir, 'change.md' )). split ( ' \n ' ). find ( l => l. trim ()) || '' ;
285 const summary = readJSON ( join (vdir, 'summary.json' ), {}) || {};
286 // Model: summary.json wins, else the most common row.model (what the app called).
287 const tally = Object. create ( null );
288 for ( const reps of Object. values (rows[v])) for ( const r of reps) if ( typeof r.model === 'string' ) tally[r.model] = (tally[r.model] || 0 ) + 1 ;
289 const rowModel = Object. entries (tally). sort (( a , b ) => b[ 1 ] - a[ 1 ])[ 0 ]?.[ 0 ] || '' ;
290 header[v] = { change: change. replace ( / ^ # + \s * / , '' ), model: summary.model || rowModel, errors: countErrors (vdir) };
291 }
292
293 // Metrics: _state.json `metrics` (or the legacy `criteria` key) wins,
294 // keeping each declared `kind`; else inferred from grade keys. Primary is
295 // the first declared-or-inferred binary metric, else the first - the same
296 // selection the full builder's lib/adapter.mjs makes, so both builders
297 // report the same metric's numbers on the same flow.
298 const metricsCfg = state.metrics || state.criteria;
299 const declared = (Array. isArray (metricsCfg) ? metricsCfg : [])
300 . map ( m => ( typeof m === 'string' ? { id: m } : { id: m?.id, kind: m?.kind }))
301 . filter ( m => m.id);
302 const metrics = declared. length ? declared : inferMetrics (rows, variants);
303 if ( ! metrics. length ) metrics. push ({ id: 'score' , kind: 'float' });
304 const primary = (metrics. find ( m => m.kind === 'binary' ) || metrics[ 0 ]).id;
305
306 // Cases: union of ids across variants, in first-seen order.
307 const ids = [];
308 const seenId = new Set ();
309 for ( const v of variants) for ( const pid of Object. keys (rows[v])) if ( ! seenId. has (pid)) { seenId. add (pid); ids. push (pid); }
310
311 const okReps = reps => reps. filter ( r => r.status == null || r.status === 'ok' );
312 const caseMean = ( reps , m ) => {
313 const vals = okReps (reps). map ( r => coerceScores (r.grade)[m]). filter (isNum);
314 return vals. length ? vals. reduce (( s , x ) => s + x, 0 ) / vals. length : null ;
315 };
316 const cases = ids. map ( pid => {
317 const first = variants. map ( v => rows[v][pid]?.[ 0 ]). find (Boolean) || {};
318 const tags = Array. isArray (first.tags) ? first.tags. map (String) : [];
319 const per = {};
320 for ( const v of variants) {
321 const reps = rows[v][pid] || [];
322 per[v] = {
323 mean: caseMean (reps, primary),
324 reps: reps. length ,
325 truncated: reps. filter ( r => r.status === 'truncated' ). length ,
326 traces: reps. map (( r , k ) => {
327 // Link only to files directly inside this variant's traces/ dir:
328 // ids and reps come from results.jsonl (data, not trusted), so the
329 // composed path must not resolve anywhere else, and a symlinked
330 // trace must not become a link out of the tree. Keep the rep
331 // number so the link label matches the file it points at even when
332 // earlier reps have no trace. For rep 0 also accept the flat
333 // traces/<id>.json a single-rep runner may write (the full builder
334 // reads it as rep 0 too).
335 // The href is resolved by a browser with URL semantics, not the
336 // filesystem's: allowlist the id to the scaffold's path-safe
337 // charset (so `#`, `?`, `\\`, `%` and separators never reach the
338 // link) and require an integer rep, on top of the traceDirOk
339 // resolved-path check above and the lexical one below.
340 if ( ! traceDirOk[v]) return null ;
341 const rep = r.rep ?? k;
342 if ( ! / ^ [A-Za-z0-9_.-] +$ / . test (pid) || / ^ \. +$ / . test (pid)) return null ;
343 if ( ! Number. isInteger ( + rep) || + rep < 0 ) return null ;
344 const stems = + rep === 0 ? [pid + '_rep' + rep, pid] : [pid + '_rep' + rep];
345 for ( const stem of stems) {
346 const rel = v + '/traces/' + stem + '.json' ;
347 const abs = resolve (flow, rel);
348 if ( dirname (abs) !== join (flow, v, 'traces' )) return null ;
349 // A hard-linked trace is no more ours than a symlinked one.
350 const ls = lstatSync (abs, { throwIfNoEntry: false });
351 if (ls?. isFile () && ! (ls.nlink > 1 )) return { rep, rel };
352 }
353 return null ;
354 }). filter (Boolean),
355 };
356 }
357 return { id: pid, split: splitOf[pid] || '' , tags, prompt: String (first.prompt ?? '' ), per };
358 });
359
360 // Per-variant aggregate: mean over cases that have a value, per split.
361 const agg = {};
362 for ( const v of variants) {
363 const all = cases. map ( c => c.per[v].mean). filter (isNum);
364 const test = cases. filter ( c => c.split === 'test' ). map ( c => c.per[v].mean). filter (isNum);
365 const m = xs => (xs. length ? xs. reduce (( s , x ) => s + x, 0 ) / xs. length : null );
366 agg[v] = { all: m (all), n: all. length , test: m (test), nTest: test. length ,
367 truncated: cases. reduce (( s , c ) => s + c.per[v].truncated, 0 ) };
368 }
369
370 // trajectory/scores.tsv - same shape as the full builder.
371 mkdirNoFollow ( join (flow, 'trajectory' ), flowRoot);
372 const tsv = [[ 'id' , 'split' , ... variants]. join ( ' \t ' )];
373 for ( const c of cases) tsv. push ([ tsvCell (c.id), tsvCell (c.split || 'all' ), ... variants. map ( v => fmt (c.per[v].mean))]. join ( ' \t ' ));
374 writeNoFollow ( join (flow, 'trajectory' , 'scores.tsv' ), tsv. join ( ' \n ' ) + ' \n ' , flowRoot);
375
376 // _state.json's `best` is {round, test_score}; map the round to a variant
377 // name the way the full builder does (0 = baseline), and drop it when the
378 // named variant isn't on disk.
379 let best = null ;
380 if (Number. isInteger (state.best?.round)) {
381 best = state.best.round === 0 ? 'baseline' : 'v' + state.best.round;
382 if ( ! variants. includes (best)) best = null ;
383 }
384
385 for ( const p of new Set (skippedHardLinks))
386 warnings. push (p. slice (flow. length + 1 ) + ' has a second hard link - not read; replace it with a plain copy if it is yours' );
387 const html = render ({ flow, variants, header, agg, cases, primary, metrics: metrics. map ( m => m.id), warnings, best });
388 writeNoFollow ( join (flow, 'report.html' ), html, flowRoot);
389 return { variants: variants. length , cases: cases. length , warnings };
390 }
391
392 function render ({ flow , variants , header , agg , cases , primary , metrics , warnings , best }) {
393 const hasSplit = cases. some ( c => c.split);
394 const hasTest = variants. some ( v => agg[v].nTest > 0 );
395 const th = ( label , key ) => '<th data-k="' + esc (key) + '">' + esc (label) + '</th>' ;
396
397 const variantRows = variants. map ( v => {
398 const h = header[v], a = agg[v];
399 const err = h.errors.total
400 ? h.errors.total + ' (' + Object. entries (h.errors.byClass). map (([ k , n ]) => esc (k) + ' ' + n). join ( ', ' ) + ')'
401 : '0' ;
402 return '<tr' + (best === v ? ' class="best"' : '' ) + '><td>' + esc (v) + (best === v ? ' <span class="pill">best</span>' : '' ) + '</td>'
403 + '<td>' + esc (h.change) + '</td><td>' + esc (h.model) + '</td>'
404 + '<td class="num">' + fmt (a.all) + '</td><td class="num">' + a.n + '</td>'
405 + (hasTest ? '<td class="num">' + fmt (a.test) + '</td><td class="num">' + a.nTest + '</td>' : '' )
406 + '<td class="num">' + a.truncated + '</td><td>' + err + '</td></tr>' ;
407 }). join ( ' \n ' );
408
409 const caseRows = cases. map ( c => {
410 const cells = variants. map ( v => {
411 const p = c.per[v];
412 const links = p.traces. map ( t => '<a href="' + esc (t.rel) + '">rep' + esc (t.rep) + '</a>' ). join ( ' ' );
413 return '<td class="num" data-v="' + fmt (p.mean) + '">' + fmt (p.mean)
414 + (p.truncated ? ' <span class="pill warn">' + p.truncated + ' truncated</span>' : '' )
415 + (links ? '<div class="links">' + links + '</div>' : '' ) + '</td>' ;
416 }). join ( '' );
417 const prompt = c.prompt. length > 600 ? c.prompt. slice ( 0 , 600 ) + '...' : c.prompt;
418 return '<tr><td class="id">' + esc (c.id) + '</td>'
419 + (hasSplit ? '<td>' + esc (c.split) + '</td>' : '' )
420 + '<td>' + c.tags. map ( t => '<span class="pill">' + esc (t) + '</span>' ). join ( ' ' ) + '</td>'
421 + cells
422 + '<td class="prompt"><details><summary>' + esc (prompt. split ( ' \n ' )[ 0 ]. slice ( 0 , 80 )) + '</summary><pre>' + esc (prompt) + '</pre></details></td></tr>' ;
423 }). join ( ' \n ' );
424
425 const warn = warnings. length
426 ? '<section class="warnings"><h2>Warnings</h2><ul>' + warnings. map ( w => '<li>' + esc (w) + '</li>' ). join ( '' ) + '</ul></section>'
427 : '' ;
428
429 return '<!DOCTYPE html> \n <html lang="en"><head><meta charset="utf-8">'
430 + '<meta name="viewport" content="width=device-width, initial-scale=1">'
431 + '<title>' + esc (flow. split ( / [ \\ /] / ). pop ()) + ' - eval report</title>'
432 + '<style>' + CSS + '</style></head><body>'
433 + '<header><h1>' + esc (flow. split ( / [ \\ /] / ). pop ()) + '</h1>'
434 + '<p class="meta">' + variants. length + ' variant' + (variants. length === 1 ? '' : 's' ) + ' · ' + cases. length + ' cases'
435 + ' · primary metric <code>' + esc (primary) + '</code>'
436 + (metrics. length > 1 ? ' (also: ' + metrics. filter ( m => m !== primary). map (esc). join ( ', ' ) + ')' : '' )
437 + ' · generated ' + new Date (). toISOString (). slice ( 0 , 19 ). replace ( 'T' , ' ' ) + ' UTC'
438 + ' · lite report (no transcripts inlined; click rep links)</p></header>'
439 + '<section><h2>Variants</h2><table><thead><tr>' + th ( 'variant' , 's' ) + th ( 'change' , 's' ) + th ( 'model' , 's' )
440 + th ( 'mean ' + primary, 'n' ) + th ( 'cases' , 'n' )
441 + (hasTest ? th ( 'test mean' , 'n' ) + th ( 'test cases' , 'n' ) : '' )
442 + th ( 'truncated' , 'n' ) + th ( 'failed attempts' , 's' ) + '</tr></thead><tbody>' + variantRows + '</tbody></table></section>'
443 + '<section><h2>Cases</h2><p class="hint">Click a column header to sort. Per-case values are the mean of <code>' + esc (primary) + '</code> over status-ok reps.</p>'
444 + '<table id="cases"><thead><tr>' + th ( 'id' , 's' ) + (hasSplit ? th ( 'split' , 's' ) : '' ) + th ( 'tags' , 's' )
445 + variants. map ( v => th (v, 'n' )). join ( '' ) + th ( 'prompt' , 's' ) + '</tr></thead><tbody>' + caseRows + '</tbody></table></section>'
446 + warn
447 + '<script>' + SORT_JS + '</script></body></html> \n ' ;
448 }
449
450 const CSS = [
451 ':root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fff;--muted:#666;--line:#ddd;--pill:#eee;--warn:#b45309;--best:#ecfdf5}' ,
452 '@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--bg:#121212;--muted:#9a9a9a;--line:#333;--pill:#2a2a2a;--warn:#f59e0b;--best:#0f2a1f}}' ,
453 'body{font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--fg);background:var(--bg);margin:0;padding:24px;max-width:1400px}' ,
454 'h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:28px 0 8px}.meta,.hint{color:var(--muted);margin:0 0 8px}' ,
455 'table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}' ,
456 'th{cursor:pointer;user-select:none;white-space:nowrap}th.asc:after{content:" \\ 25B4"}th.desc:after{content:" \\ 25BE"}' ,
457 'td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}td.id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}' ,
458 '.pill{display:inline-block;background:var(--pill);border-radius:10px;padding:0 8px;font-size:12px}.pill.warn{color:var(--warn)}' ,
459 'tr.best td{background:var(--best)}.links{font-size:12px}.links a{margin-right:6px}' ,
460 'td.prompt{max-width:520px}details summary{cursor:pointer;color:var(--muted)}pre{white-space:pre-wrap;font-size:12px;margin:6px 0 0}' ,
461 '.warnings{color:var(--warn)}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}' ,
462 ]. join ( '' );
463
464 const SORT_JS = [
465 'document.querySelectorAll("th[data-k]").forEach(function(th){th.addEventListener("click",function(){' ,
466 'var t=th.closest("table"),tb=t.tBodies[0],i=Array.prototype.indexOf.call(th.parentNode.children,th);' ,
467 'var asc=!th.classList.contains("asc");t.querySelectorAll("th").forEach(function(h){h.classList.remove("asc","desc")});' ,
468 'th.classList.add(asc?"asc":"desc");var num=th.dataset.k==="n";' ,
469 'var rows=Array.prototype.slice.call(tb.rows);rows.sort(function(a,b){var x=a.cells[i],y=b.cells[i];' ,
470 'var u=num?parseFloat("v" in x.dataset?x.dataset.v:x.textContent):x.textContent.trim().toLowerCase();' ,
471 'var w=num?parseFloat("v" in y.dataset?y.dataset.v:y.textContent):y.textContent.trim().toLowerCase();' ,
472 'if(num){if(isNaN(u))u=-Infinity;if(isNaN(w))w=-Infinity}return (u<w?-1:u>w?1:0)*(asc?1:-1)});' ,
473 'rows.forEach(function(r){tb.appendChild(r)})})});' ,
474 ]. join ( '' );
475
476 const arg = process.argv[ 2 ];
477 if ( ! arg || arg === '-h' || arg === '--help' ) {
478 eprint ( 'usage: node build-report-lite.mjs <flow-dir> (e.g. .claude/hillclimb/<flow>/)' );
479 process. exit (arg ? 0 : 2 );
480 }
481 try {
482 const { variants , cases , warnings } = build (arg);
483 for ( const w of warnings) eprint ( 'warning: ' + w);
484 eprint ( 'wrote ' + join ( resolve (arg), 'report.html' ) + ' (' + variants + ' variants, ' + cases + ' cases) and trajectory/scores.tsv' );
485 } catch (e) {
486 eprint ( 'build-report-lite: ' + (e?.message || e));
487 process. exit ( 1 );
488 }