@xsft/panels 0.5.2 → 0.7.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 +5 -3
- package/bin/panels.js +6 -9
- package/lib/judge.js +32 -45
- package/lib/report.js +11 -35
- package/lib/table.js +236 -115
- package/package.json +1 -1
- package/prompts/judge-system-prompt.txt +22 -50
- package/schema/report.json +11 -98
package/README.md
CHANGED
|
@@ -29,16 +29,18 @@ Run the command in the folder of the product. Each judge starts in that folder,
|
|
|
29
29
|
|
|
30
30
|
## Terminal mode
|
|
31
31
|
|
|
32
|
-
On a terminal, `panels run` shows the
|
|
32
|
+
On a terminal, `panels run` shows the overview, and each judge runs in its interactive harness in a pseudo-terminal inside the command. Each judge writes `judges/N/answer.json` and stays open. The overview writes the report when each judge has an answer or an error, and the command exits from `report.json` when you quit. Without a terminal, the judges run in the background (`claude -p`, `codex exec`), and stdout prints `N/4 name` and the verdict of each criterion, then the report path.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
The overview has a header line with the run (panel, target, harness, elapsed time) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red), the time the judge has run, and the tokens it used, with a total row under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, the verdict paragraph, the key line, and a grey fact line with the judge number, the harness, the time, and the answer file. One soft wave sweeps across the running rows. On the overview, `↑` and `↓` move the cursor, `r` restarts the judge of the cursor row with its harness (its answer is dropped and the report is written again when it finishes), and `h` flips that harness between `claude` and `codex`. The page never says pass or fail; the total is the result.
|
|
35
|
+
|
|
36
|
+
A tab strip on the last three lines shows a hairline, the tabs (`overview`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
|
|
35
37
|
|
|
36
38
|
## Run folder
|
|
37
39
|
|
|
38
40
|
Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
|
|
39
41
|
|
|
40
42
|
- `panel.json`: the panel as the api gave it.
|
|
41
|
-
- `report.
|
|
43
|
+
- `report.json`: `{ panel: "owner/name@version", target, status, criteria: [{ name, score, verdict }] }`, in panel order. `schema/report.json` is its schema, and its `criterion` definition is the shape of the answer of a judge.
|
|
42
44
|
- `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k. In terminal mode, `answer.json` takes the place of the raw answer.
|
|
43
45
|
|
|
44
46
|
The last line on stdout is the path of `report.json`.
|
package/bin/panels.js
CHANGED
|
@@ -4,7 +4,7 @@ import { delimiter, join } from 'node:path';
|
|
|
4
4
|
import { parse, SYNOPSIS } from '../lib/args.js';
|
|
5
5
|
import { runJudge } from '../lib/judge.js';
|
|
6
6
|
import { fetchPanel } from '../lib/panel.js';
|
|
7
|
-
import { buildReport,
|
|
7
|
+
import { buildReport, renderPlain } from '../lib/report.js';
|
|
8
8
|
import { runTable } from '../lib/table.js';
|
|
9
9
|
|
|
10
10
|
// More parallel sessions on one account get 429 answers from the model api.
|
|
@@ -62,27 +62,24 @@ async function main() {
|
|
|
62
62
|
}
|
|
63
63
|
const { panel } = fetched;
|
|
64
64
|
const cwd = process.cwd();
|
|
65
|
-
const judges = panel.criteria.map((_, i) => [i]);
|
|
66
65
|
const time = new Date().toISOString().replaceAll(':', '-');
|
|
67
66
|
const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, time);
|
|
68
67
|
mkdirSync(runDir, { recursive: true });
|
|
69
68
|
writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
|
|
70
69
|
if (process.stdout.isTTY && process.stdin.isTTY) return runOnTerminal({ panel, runDir, harness, cwd, args });
|
|
71
|
-
const outcomes = await pool(
|
|
72
|
-
dir: join(runDir, 'judges', String(
|
|
73
|
-
|
|
70
|
+
const outcomes = await pool(panel.criteria, PARALLEL_JUDGES, (c, i) => runJudge({
|
|
71
|
+
dir: join(runDir, 'judges', String(i + 1)),
|
|
72
|
+
criterion: { rank: i + 1, ...c },
|
|
74
73
|
harness,
|
|
75
74
|
cwd,
|
|
76
75
|
target: args.target,
|
|
77
76
|
brief: args.brief,
|
|
78
77
|
sources: args.sources,
|
|
79
78
|
}));
|
|
80
|
-
const report = buildReport({ panel, target: args.target,
|
|
81
|
-
const markdown = renderMarkdown(report);
|
|
79
|
+
const report = buildReport({ panel, target: args.target, outcomes });
|
|
82
80
|
const reportPath = join(runDir, 'report.json');
|
|
83
|
-
writeFileSync(join(runDir, 'report.md'), markdown);
|
|
84
81
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
85
|
-
console.log(
|
|
82
|
+
console.log(renderPlain(report).join('\n'));
|
|
86
83
|
console.log(reportPath);
|
|
87
84
|
return EXIT[report.status];
|
|
88
85
|
}
|
package/lib/judge.js
CHANGED
|
@@ -2,54 +2,41 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
|
|
5
|
-
const {
|
|
5
|
+
const { criterion: CRITERION } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
|
|
6
|
+
const VERDICT_LENGTH = CRITERION.properties.verdict.maxLength;
|
|
6
7
|
const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
|
|
7
8
|
export const MINUTES_PER_CRITERION = 20;
|
|
8
9
|
const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
|
|
9
10
|
// With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
|
|
10
11
|
const ALLOWED_TOOLS = 'Read,Glob,Grep,WebFetch,WebSearch,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git status:*),Bash(git ls-files:*),Bash(git rev-parse:*),Bash(git branch --list:*)';
|
|
11
12
|
|
|
12
|
-
export const bandToScore = (band) => (band <= 3 ? 1 : band <= 6 ? 2 : band <= 8 ? 3 : 4);
|
|
13
|
-
|
|
14
13
|
/**
|
|
15
|
-
* The user message of a judge.
|
|
16
|
-
*
|
|
14
|
+
* The user message of a judge. criterion is { rank, name, description, body }. Each part sits in a tag,
|
|
15
|
+
* because a body is markdown with headings of its own.
|
|
17
16
|
*/
|
|
18
|
-
export function userMessage(
|
|
19
|
-
const parts =
|
|
17
|
+
export function userMessage(criterion, { target, brief, sources }) {
|
|
18
|
+
const parts = [`<criterion rank="${criterion.rank}" name="${criterion.name}">\nDescription: ${criterion.description}\n\n${criterion.body}\n</criterion>`];
|
|
20
19
|
parts.push(`<target>\n${target}\n</target>`, `<brief>\n${brief ?? 'none supplied'}\n</brief>`, `<sources>\n${sources.length ? sources.join('\n') : 'none'}\n</sources>`);
|
|
21
20
|
return `${parts.join('\n\n')}\n`;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
/** The output schema of a judge:
|
|
25
|
-
export function judgeSchema(
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
properties: { ...judgeResult.properties, rank: { type: 'integer', const: c.rank }, criterion: { type: 'string', const: c.name } },
|
|
29
|
-
}));
|
|
30
|
-
return {
|
|
31
|
-
type: 'object',
|
|
32
|
-
additionalProperties: false,
|
|
33
|
-
required: ['results'],
|
|
34
|
-
properties: { results: { type: 'array', minItems: items.length, maxItems: items.length, items: items.length === 1 ? items[0] : { anyOf: items } } },
|
|
35
|
-
};
|
|
23
|
+
/** The output schema of a judge: one criterion object { name, score, verdict }, with the name fixed. */
|
|
24
|
+
export function judgeSchema(criterion) {
|
|
25
|
+
const { description, ...schema } = CRITERION;
|
|
26
|
+
return { ...schema, properties: { ...schema.properties, name: { type: 'string', const: criterion.name } } };
|
|
36
27
|
}
|
|
37
28
|
|
|
38
29
|
/** Returns the reasons the answer of a judge is not valid, or an empty array. */
|
|
39
|
-
export function check(answer,
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
else if (r.score !== bandToScore(r.band)) errors.push(`Rank ${c.rank}: band ${r.band} maps to score ${bandToScore(r.band)}, not ${r.score}.`);
|
|
50
|
-
if (r.score < 4 && !(Array.isArray(r.fixes) && r.fixes.length)) errors.push(`Rank ${c.rank}: score ${r.score} is below 4, so fixes must list each fix that score 4 needs.`);
|
|
51
|
-
return errors;
|
|
52
|
-
});
|
|
30
|
+
export function check(answer, criterion) {
|
|
31
|
+
if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) return ['The answer must be one JSON object with name, score, and verdict.'];
|
|
32
|
+
const missing = CRITERION.required.filter((key) => !(key in answer));
|
|
33
|
+
if (missing.length) return [`The answer lacks ${missing.join(', ')}.`];
|
|
34
|
+
const errors = [];
|
|
35
|
+
if (answer.name !== criterion.name) errors.push(`name must be "${criterion.name}".`);
|
|
36
|
+
if (!Number.isInteger(answer.score) || answer.score < 1 || answer.score > 4) errors.push('score must be a whole number from 1 to 4.');
|
|
37
|
+
if (typeof answer.verdict !== 'string' || !answer.verdict.trim()) errors.push('verdict must be one paragraph of text.');
|
|
38
|
+
else if (answer.verdict.length > VERDICT_LENGTH) errors.push(`verdict must be ${VERDICT_LENGTH} characters at most; it has ${answer.verdict.length}.`);
|
|
39
|
+
return errors;
|
|
53
40
|
}
|
|
54
41
|
|
|
55
42
|
/** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
|
|
@@ -123,25 +110,25 @@ export function parseAnswer(text) {
|
|
|
123
110
|
|
|
124
111
|
/**
|
|
125
112
|
* Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
|
|
126
|
-
* Returns {
|
|
113
|
+
* Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
|
|
127
114
|
*/
|
|
128
|
-
export async function runJudge({ dir,
|
|
115
|
+
export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources }, command = harness) {
|
|
129
116
|
mkdirSync(dir, { recursive: true });
|
|
130
|
-
const prompt = userMessage(
|
|
131
|
-
const schema = judgeSchema(
|
|
117
|
+
const prompt = userMessage(criterion, { target, brief, sources });
|
|
118
|
+
const schema = judgeSchema(criterion);
|
|
132
119
|
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
133
120
|
writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
|
|
134
|
-
const j = { dir, cwd, schema, command, addDirs: addDirs(cwd, brief, sources), timeout:
|
|
121
|
+
const j = { dir, cwd, schema, command, addDirs: addDirs(cwd, brief, sources), timeout: MINUTES_PER_CRITERION * 60_000 };
|
|
135
122
|
const ask = harness === 'claude' ? askClaude : askCodex;
|
|
136
123
|
let reply = await ask(j, prompt);
|
|
137
124
|
if (reply.error) return reply;
|
|
138
|
-
let errors = check(reply.answer,
|
|
125
|
+
let errors = check(reply.answer, criterion);
|
|
139
126
|
if (errors.length) {
|
|
140
127
|
reply = await ask(j, `Your answer failed these checks. Return the whole JSON object again, with each one fixed:\n${errors.map((e) => `- ${e}`).join('\n')}`, reply.session);
|
|
141
128
|
if (reply.error) return reply;
|
|
142
|
-
errors = check(reply.answer,
|
|
129
|
+
errors = check(reply.answer, criterion);
|
|
143
130
|
}
|
|
144
|
-
return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : {
|
|
131
|
+
return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { answer: reply.answer };
|
|
145
132
|
}
|
|
146
133
|
|
|
147
134
|
const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
|
|
@@ -153,12 +140,12 @@ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOM
|
|
|
153
140
|
* Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
|
|
154
141
|
* arguments that start the judge in its interactive harness.
|
|
155
142
|
*/
|
|
156
|
-
export function writeJudge({ dir,
|
|
143
|
+
export function writeJudge({ dir, criterion, harness, cwd, target, brief, sources }) {
|
|
157
144
|
mkdirSync(dir, { recursive: true });
|
|
158
|
-
const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer JSON,
|
|
159
|
-
const prompt = `${userMessage(
|
|
145
|
+
const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer, one JSON object { name, score, verdict } that matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Write that file yourself with a tool, and do not start sub-agents: the JSON in your reply does not count. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
|
|
146
|
+
const prompt = `${userMessage(criterion, { target, brief, sources })}\n${closing}`;
|
|
160
147
|
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
161
|
-
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(
|
|
148
|
+
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criterion), null, 2));
|
|
162
149
|
if (harness === 'codex') {
|
|
163
150
|
// Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
|
|
164
151
|
// write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
|
package/lib/report.js
CHANGED
|
@@ -1,43 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Builds report.json
|
|
3
|
-
* {
|
|
4
|
-
*
|
|
2
|
+
* Builds report.json: { panel: "owner/name@version", target, status, criteria }. outcomes holds the
|
|
3
|
+
* { answer } or { error } of the judge of each criterion, in panel order. A criterion whose judge failed
|
|
4
|
+
* gets score null and the error as its verdict, and the run status is error. The panel passes only when
|
|
5
|
+
* every criterion scores 4.
|
|
5
6
|
*/
|
|
6
|
-
export function buildReport({ panel, target,
|
|
7
|
-
const errors = outcomes.flatMap((o, j) => (o.error ? [{ judge: j + 1, message: o.error }] : []));
|
|
8
|
-
const failed = new Set(errors.flatMap((e) => judges[e.judge - 1]));
|
|
9
|
-
const answers = judges.flatMap((indexes, j) => (outcomes[j].results ?? []).map((result, x) => ({ i: indexes[x], judge: j + 1, result })));
|
|
7
|
+
export function buildReport({ panel, target, outcomes }) {
|
|
10
8
|
const criteria = panel.criteria.map((c, i) => {
|
|
11
|
-
const
|
|
12
|
-
return
|
|
9
|
+
const { answer, error } = outcomes[i];
|
|
10
|
+
return answer ? { name: c.name, score: answer.score, verdict: answer.verdict } : { name: c.name, score: null, verdict: `error: ${error}` };
|
|
13
11
|
});
|
|
14
12
|
const status = criteria.some((c) => c.score === null) ? 'error' : criteria.every((c) => c.score === 4) ? 'pass' : 'fail';
|
|
15
|
-
return {
|
|
13
|
+
return { panel: `${panel.owner}/${panel.name}@${panel.version}`, target, status, criteria };
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const { status, panel, target, criteria, errors } = report;
|
|
22
|
-
const lines = [
|
|
23
|
-
`${status.toUpperCase()} ${panel.owner}/${panel.name} v${panel.version} on ${JSON.stringify(target)} (${criteria.filter((c) => c.score === 4).length} of ${criteria.length} criteria at 4)`,
|
|
24
|
-
'',
|
|
25
|
-
'| # | Criterion | Score | Verdict |',
|
|
26
|
-
'|---|---|---|---|',
|
|
27
|
-
];
|
|
28
|
-
for (const c of criteria) {
|
|
29
|
-
const lowest = c.judges.find((a) => a.result.score === c.score);
|
|
30
|
-
lines.push(`| ${c.rank} | ${cell(c.name)} | ${c.score ?? 'error'} | ${cell(lowest?.result.verdict ?? '')} |`);
|
|
31
|
-
}
|
|
32
|
-
const list = (title, items) => items.length && lines.push(title, ...items.map((item) => `- ${item}`));
|
|
33
|
-
for (const c of criteria) {
|
|
34
|
-
for (const { result } of c.judges) {
|
|
35
|
-
lines.push('', `## ${c.rank}. ${c.name}: ${result.score} (band ${result.band})`);
|
|
36
|
-
list('Evidence', result.evidence.map((e) => `${e.where}: ${e.quote ?? e.claim}`));
|
|
37
|
-
list('Fixes', result.fixes.map((f) => `${f.problem} ${f.change} (${f.where})`));
|
|
38
|
-
list('Not checked', result.notChecked.map((n) => `${n.test}: ${n.reason}`));
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (errors.length) lines.push('', '## Errors', ...errors.map((e) => `- judge ${e.judge}: ${e.message}`));
|
|
42
|
-
return `${lines.join('\n')}\n`;
|
|
16
|
+
/** The lines that the plain mode prints: "N/4 name" and the verdict under it, for each criterion. */
|
|
17
|
+
export function renderPlain(report) {
|
|
18
|
+
return report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, '']);
|
|
43
19
|
}
|
package/lib/table.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// The terminal mode of `panels run`: the
|
|
1
|
+
// The terminal mode of `panels run`: the overview, and a view of each judge. Each judge runs its interactive
|
|
2
2
|
// harness in a pseudo-terminal, and a headless terminal keeps its screen for the switch to its view.
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { join
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
5
|
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
|
6
6
|
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
7
7
|
import xterm from '@xterm/headless';
|
|
8
8
|
import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
|
|
9
|
-
import { buildReport
|
|
9
|
+
import { buildReport } from './report.js';
|
|
10
10
|
|
|
11
11
|
const LIVE_JUDGES = 10;
|
|
12
12
|
// A harness asks this in a folder it has not seen. Only the first judge starts until its screen no longer asks,
|
|
@@ -17,117 +17,207 @@ const ORANGE = '\x1b[38;2;255;106;61m';
|
|
|
17
17
|
const GREY = '\x1b[90m';
|
|
18
18
|
const BOLD = '\x1b[1m';
|
|
19
19
|
const RESET = '\x1b[0m';
|
|
20
|
+
// The colour of a score, from the 16-colour palette: 4 green, 3 the default ink, 2 yellow, 1 red.
|
|
21
|
+
const SCORE_COLOUR = { 4: '\x1b[32m', 3: '', 2: '\x1b[33m', 1: '\x1b[31m' };
|
|
22
|
+
const LEVELS = { 1: 'Fails', 2: 'Falls short', 3: 'Almost met', 4: 'Met' };
|
|
20
23
|
// ctrl-q moves the focus between a judge and the tab strip. A terminal in the kitty keyboard mode sends it as CSI 113;5u.
|
|
21
24
|
const TOGGLE = ['\x11', '\x1b[113;5u'];
|
|
22
25
|
const RIGHT = ['\x1b[C', '\x1bOC'];
|
|
23
26
|
const LEFT = ['\x1b[D', '\x1bOD'];
|
|
27
|
+
const UP = ['\x1b[A', '\x1bOA'];
|
|
28
|
+
const DOWN = ['\x1b[B', '\x1bOB'];
|
|
24
29
|
// Enter, Esc, or ctrl-q give the focus back to the judge of the active tab.
|
|
25
30
|
const BACK = ['\r', '\x1b', ...TOGGLE];
|
|
26
|
-
// Turns off each mode that the screen of a judge can turn on, so the
|
|
31
|
+
// Turns off each mode that the screen of a judge can turn on, so the overview gets the keys and the screen as before.
|
|
27
32
|
const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b>\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[<u\x1b[?25l\x1b[2J';
|
|
33
|
+
// The glimmer on the text of the running judges: grey text, and one soft wave three columns wide that moves one
|
|
34
|
+
// column per frame from the left edge of the screen to the right edge and starts over. A running label lights
|
|
35
|
+
// up only where the wave passes over its own columns, so every running label lights at the same columns.
|
|
36
|
+
const GLIMMER = { base: '\x1b[38;5;244m', mid: '\x1b[38;5;247m', high: '\x1b[38;5;250m' };
|
|
37
|
+
const GLIMMER_ORANGE = { base: ORANGE, mid: '\x1b[38;2;255;140;100m', high: '\x1b[38;2;255;175;145m' };
|
|
38
|
+
const WAVE = 3;
|
|
39
|
+
// The tab strip takes the last three lines of the terminal: a hairline, the tabs, then the status and the keys.
|
|
40
|
+
const STRIP_ROWS = 3;
|
|
41
|
+
// The overview shows two columns from this width up, and the list above the detail below it.
|
|
42
|
+
const TWO_COLUMNS = 100;
|
|
28
43
|
|
|
29
44
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
45
|
+
* text with the glimmer at frame, where the text starts at the screen column start (0-based) on a screen width
|
|
46
|
+
* columns wide. colours is { base, mid, high }; prefix (such as bold) goes before each piece.
|
|
33
47
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const GLIMMER = { base: '\x1b[38;5;244m', mid: '\x1b[38;5;250m', high: '\x1b[38;5;255m' };
|
|
37
|
-
const GLIMMER_ORANGE = { base: ORANGE, mid: '\x1b[38;2;255;170;140m', high: '\x1b[38;5;255m' };
|
|
38
|
-
|
|
39
|
-
/** text with the glimmer at frame. colours is { base, mid, high }; prefix (such as bold) goes before each piece. */
|
|
40
|
-
export function glimmer(text, frame, colours = GLIMMER, prefix = '') {
|
|
41
|
-
const centre = frame % text.length;
|
|
48
|
+
export function glimmer(text, frame, start, width, colours = GLIMMER, prefix = '') {
|
|
49
|
+
const centre = (frame % (width + WAVE)) - 1;
|
|
42
50
|
return [...text].map((c, i) => {
|
|
43
|
-
const d = Math.
|
|
51
|
+
const d = Math.abs(start + i - centre);
|
|
44
52
|
return `${prefix}${d === 0 ? colours.high : d === 1 ? colours.mid : colours.base}${c}`;
|
|
45
53
|
}).join('') + RESET;
|
|
46
54
|
}
|
|
47
|
-
// The tab strip takes the last three lines of the terminal: a hairline, the tabs, then the status and the keys.
|
|
48
|
-
const STRIP_ROWS = 3;
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
/** m:ss for a number of seconds. */
|
|
57
|
+
export const clock = (seconds) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
58
|
+
|
|
59
|
+
/** "12.4k" for 12400 tokens, "0.3k" for 300, "1.2M" from a million, and "" for none. */
|
|
60
|
+
export function tokensText(n) {
|
|
61
|
+
if (n === null || n === undefined) return '';
|
|
62
|
+
return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${(n / 1000).toFixed(1)}k`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The largest token count on the screen of a judge, or null. Claude shows "↓ 354 tokens" and a context count
|
|
67
|
+
* such as "12532 tokens"; Codex shows its count with a k or M suffix. The largest number before "tokens" wins.
|
|
68
|
+
*/
|
|
69
|
+
export function tokensOnScreen(text) {
|
|
70
|
+
let best = null;
|
|
71
|
+
for (const m of text.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([kKmM])?\s*tokens/g)) {
|
|
72
|
+
const n = Number(m[1].replaceAll(',', '')) * (m[2] ? { k: 1e3, m: 1e6 }[m[2].toLowerCase()] : 1);
|
|
73
|
+
if (best === null || n > best) best = n;
|
|
61
74
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
return best;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const plainLength = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
79
|
+
const padTo = (s, width) => s + ' '.repeat(Math.max(0, width - plainLength(s)));
|
|
80
|
+
|
|
81
|
+
/** The first width visible characters of a styled line, with every escape sequence kept, so the line never wraps. */
|
|
82
|
+
export function clip(line, width) {
|
|
83
|
+
let seen = 0;
|
|
84
|
+
let out = '';
|
|
85
|
+
for (const part of line.split(/(\x1b\[[0-9;]*m)/)) {
|
|
86
|
+
if (part.startsWith('\x1b')) { out += part; continue; }
|
|
87
|
+
const room = Math.max(0, width - seen);
|
|
88
|
+
out += [...part].slice(0, room).join('');
|
|
89
|
+
seen += Math.min([...part].length, room);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The words of text in lines of at most width characters. */
|
|
95
|
+
export function wrap(text, width) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
let line = '';
|
|
98
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
99
|
+
if (line && line.length + 1 + word.length > width) { lines.push(line); line = word; }
|
|
100
|
+
else line = line ? `${line} ${word}` : word;
|
|
66
101
|
}
|
|
67
|
-
if (
|
|
102
|
+
if (line) lines.push(line);
|
|
68
103
|
return lines;
|
|
69
104
|
}
|
|
70
105
|
|
|
106
|
+
/** The scores added as quarters: 1/4 + 1/4 + 2/4 is "1". No trailing zeros. */
|
|
107
|
+
const quarters = (sum) => String(Math.round((sum / 4) * 100) / 100);
|
|
108
|
+
|
|
71
109
|
/**
|
|
72
|
-
* The
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* orange, the arrows move it) or 'judge' (the active tab is bold ink, the keys go to the judge).
|
|
110
|
+
* The lines of the overview. rows hold { rank, name, state, result, error, trust, time, tokens, harness, restarted },
|
|
111
|
+
* and state is queued, running, done, or error. time is the seconds the judge has run, tokens the count it used
|
|
112
|
+
* (null for none), harness the one its next restart uses, restarted the seconds since a restart (null for none).
|
|
113
|
+
* cursor is the index of the row whose detail shows at the right. The header line holds the run: panel, target,
|
|
114
|
+
* harness, elapsed. Two columns from TWO_COLUMNS wide, the list above the detail below that.
|
|
78
115
|
*/
|
|
79
|
-
export function
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
116
|
+
export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, frame = 0 }) {
|
|
117
|
+
const header = ` ${BOLD}panels run${RESET} ${title}${GREY} ${target} ${harness} ${clock(elapsed)}${RESET}`;
|
|
118
|
+
const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
|
|
119
|
+
const pad = Math.max(...rows.map((r) => r.name.length));
|
|
120
|
+
const leftWidth = pad + 27;
|
|
121
|
+
const cells = (score, time, tokens) => ` ${score.padStart(3)} ${time.padStart(5)} ${tokens.padStart(6)}`;
|
|
122
|
+
const left = rows.map((r, i) => {
|
|
123
|
+
const current = i === cursor;
|
|
124
|
+
const mark = current ? `${ORANGE}▸${RESET} ` : ' ';
|
|
125
|
+
const rank = `${String(r.rank).padStart(2)} `;
|
|
126
|
+
const time = r.time === null ? '' : clock(r.time);
|
|
127
|
+
const tokens = tokensText(r.tokens);
|
|
128
|
+
if (r.state === 'running' && !r.trust) {
|
|
129
|
+
const name = glimmer(r.name.padEnd(pad), frame, 6, width, current ? GLIMMER_ORANGE : GLIMMER);
|
|
130
|
+
return `${mark}${current ? ORANGE : ''}${rank}${RESET}${name}${current ? ORANGE : GREY}${cells('', time, tokens)}${RESET}`;
|
|
131
|
+
}
|
|
132
|
+
const score = r.state === 'done' ? `${r.result.score}/4` : r.state === 'error' ? '✗' : '';
|
|
133
|
+
const colour = current ? ORANGE : r.state === 'done' ? SCORE_COLOUR[r.result.score] : r.state === 'error' ? SCORE_COLOUR[1] : '';
|
|
134
|
+
const name = current ? `${ORANGE}${r.name.padEnd(pad)}${RESET}` : r.state === 'queued' ? `${GREY}${r.name.padEnd(pad)}${RESET}` : r.name.padEnd(pad);
|
|
135
|
+
const rest = r.state === 'running' ? `${ORANGE}press ${r.rank} and answer the trust prompt${RESET}` : `${colour}${cells(score, time, tokens)}${RESET}`;
|
|
136
|
+
return `${mark}${current ? ORANGE : ''}${rank}${RESET}${name}${rest}`;
|
|
137
|
+
});
|
|
138
|
+
const done = rows.filter((r) => r.state === 'done');
|
|
139
|
+
const total = cells(`${quarters(done.reduce((n, r) => n + r.result.score, 0))}/${rows.length}`, clock(Math.max(0, ...rows.map((r) => r.time ?? 0))), tokensText(rows.some((r) => r.tokens !== null) ? rows.reduce((n, r) => n + (r.tokens ?? 0), 0) : null));
|
|
140
|
+
left.push(`${GREY}${'─'.repeat(leftWidth)}${RESET}`, `${padTo(' total', leftWidth - total.length)}${total}`);
|
|
141
|
+
|
|
142
|
+
const row = rows[cursor];
|
|
143
|
+
const right = [];
|
|
144
|
+
const detail = (columnWidth) => {
|
|
145
|
+
right.length = 0;
|
|
146
|
+
const level = row.state === 'done' ? `${row.result.score}/4 ${LEVELS[row.result.score]}` : row.state === 'error' ? 'error' : row.state;
|
|
147
|
+
const name = `${row.rank} ${row.name.toUpperCase()}`;
|
|
148
|
+
right.push(`${BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`, '');
|
|
149
|
+
const text = row.state === 'done' ? row.result.verdict
|
|
150
|
+
: row.state === 'error' ? row.error
|
|
151
|
+
: row.state === 'queued' ? 'The judge has not started.'
|
|
152
|
+
: row.restarted !== null ? `Restarted with ${row.harness} ${clock(row.restarted)} ago. Press ${row.rank} to watch it.`
|
|
153
|
+
: `The judge has run for ${clock(row.time ?? 0)}. Press ${row.rank} to watch it.`;
|
|
154
|
+
right.push(...wrap(text, columnWidth), '');
|
|
155
|
+
right.push(`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`, '');
|
|
156
|
+
const facts = [`judge ${row.rank}`, row.harness, ...(row.time === null ? [] : [clock(row.time)]), ...(row.state === 'done' || row.state === 'error' ? [`judges/${row.rank}/answer.json`] : [])];
|
|
157
|
+
right.push(`${GREY}${facts.join(' · ')}${RESET}`);
|
|
85
158
|
};
|
|
159
|
+
|
|
160
|
+
const lines = [header, rule];
|
|
161
|
+
if (width >= TWO_COLUMNS) {
|
|
162
|
+
detail(width - leftWidth - 3);
|
|
163
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) lines.push(`${padTo(left[i] ?? '', leftWidth)}${GREY} │ ${RESET}${right[i] ?? ''}`);
|
|
164
|
+
} else {
|
|
165
|
+
detail(width - 2);
|
|
166
|
+
lines.push(...left, '', ...right.map((l) => ` ${l}`));
|
|
167
|
+
}
|
|
168
|
+
return lines.map((l) => clip(l, width));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
|
|
173
|
+
* the elapsed time at the left and the keys of the focus at the right. The first tab is the overview, then the
|
|
174
|
+
* name of each criterion, with a grey │ between the tabs. A running tab glimmers. The names step down to the
|
|
175
|
+
* first word when they do not fit the width. focus is 'strip' (the active tab is bold orange, the arrows move
|
|
176
|
+
* it) or 'judge' (the active tab is bold ink, the keys go to the judge). confirm replaces the progress with
|
|
177
|
+
* the quit question.
|
|
178
|
+
*/
|
|
179
|
+
export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', elapsed = 0, confirm = false }) {
|
|
86
180
|
const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
...rows.map((r) => ({ rank: String(r.rank), name: level === 0 ? r.name : level === 1 ? r.name.split(' ')[0] : '', mark: mark(r) })),
|
|
92
|
-
];
|
|
93
|
-
const plain = (t) => [t.rank, t.name, t.mark].filter(Boolean).join(' ');
|
|
94
|
-
const used = (list) => 1 + list.reduce((n, t) => n + plain(t).length, 0) + 2 * (list.length - 1);
|
|
95
|
-
const list = [0, 1, 2].map(tabs).find((l) => used(l) <= width) ?? tabs(2);
|
|
181
|
+
const keys = focus === 'judge' ? 'ctrl-q tabs' : view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
|
|
182
|
+
const names = (level) => ['overview', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
|
|
183
|
+
const used = (list) => 1 + list.reduce((n, t) => n + t.length, 0) + 3 * (list.length - 1);
|
|
184
|
+
const list = [0, 1].map(names).find((l) => used(l) <= width) ?? names(1);
|
|
96
185
|
let line = ' ';
|
|
97
|
-
|
|
98
|
-
|
|
186
|
+
let col = 1;
|
|
187
|
+
list.forEach((text, i) => {
|
|
188
|
+
if (i) { line += `${GREY} │ ${RESET}`; col += 3; }
|
|
99
189
|
const active = i === view;
|
|
100
|
-
const base = active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY;
|
|
101
|
-
const text = plain(t);
|
|
102
190
|
const row = rows[i - 1];
|
|
191
|
+
const start = col;
|
|
192
|
+
col += text.length;
|
|
103
193
|
if (row?.state === 'running' && !row.trust) {
|
|
104
|
-
line += glimmer(text, frame, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
|
|
194
|
+
line += glimmer(text, frame, start, width, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
|
|
105
195
|
return;
|
|
106
196
|
}
|
|
107
|
-
|
|
108
|
-
if (t.rank) pieces.push(`${base}${t.rank}${RESET}`);
|
|
109
|
-
if (t.name) pieces.push(`${base}${t.name}${RESET}`);
|
|
110
|
-
if (t.mark) pieces.push(`${row.state === 'done' && row.result.score === 4 ? `${base}${BOLD}` : base}${t.mark}${RESET}`);
|
|
111
|
-
line += pieces.join(' ');
|
|
197
|
+
line += `${active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY}${text}${RESET}`;
|
|
112
198
|
});
|
|
113
|
-
const progress = `${finished} of ${rows.length} done · ${clock}`;
|
|
114
|
-
|
|
115
|
-
|
|
199
|
+
const progress = confirm ? 'judges still run. q again quits, another key stays' : `${finished} of ${rows.length} done · ${clock(elapsed)}`;
|
|
200
|
+
// A narrow terminal drops the keys before the progress, and then the end of the progress.
|
|
201
|
+
const gap = width - progress.length - keys.length - 2;
|
|
202
|
+
const status = gap >= 2 ? ` ${progress}${' '.repeat(gap)}${keys}` : ` ${progress}`;
|
|
203
|
+
return [`${GREY}${'─'.repeat(width)}${RESET}`, clip(line, width), `${GREY}${clip(status, width)}${RESET}`];
|
|
116
204
|
}
|
|
117
205
|
|
|
118
206
|
/**
|
|
119
|
-
* The next { view, confirm, focus } after one key, plus send: true when the key goes to the judge,
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* focus on
|
|
123
|
-
*
|
|
124
|
-
*
|
|
207
|
+
* The next { view, confirm, focus, cursor } after one key, plus send: true when the key goes to the judge,
|
|
208
|
+
* quit: true, restart: N to restart judge N, or flip: N to flip the harness of judge N. view is 0 for the
|
|
209
|
+
* overview, or N for judge N. open holds the ranks of the judges that have started, count the number of
|
|
210
|
+
* criteria. With the focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to
|
|
211
|
+
* the strip. With the focus on the strip, the arrows left and right move the active tab and switch the view,
|
|
212
|
+
* 1-9 jump to a started judge, 0 to the overview, Enter, Esc, or ctrl-q give the focus back to the judge, and
|
|
213
|
+
* q quits (a second q confirms while a judge runs). On the overview, which takes no text, up and down move the
|
|
214
|
+
* cursor, r restarts the judge of the cursor row, and h flips its harness.
|
|
125
215
|
*/
|
|
126
|
-
export function press({ view, confirm, focus }, key, { open, running }) {
|
|
127
|
-
const strip = (v) => ({ view: v, confirm: false, focus: 'strip' });
|
|
128
|
-
if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, send: true };
|
|
129
|
-
if (confirm) return { ...strip(0), quit: key === '
|
|
130
|
-
if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip' } : { ...strip(0), quit: true };
|
|
216
|
+
export function press({ view, confirm, focus, cursor }, key, { open, running, count }) {
|
|
217
|
+
const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor });
|
|
218
|
+
if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, send: true };
|
|
219
|
+
if (confirm) return { ...strip(0), quit: key === 'q' };
|
|
220
|
+
if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor } : { ...strip(0), quit: true };
|
|
131
221
|
if (RIGHT.includes(key) || LEFT.includes(key)) {
|
|
132
222
|
const cycle = [0, ...open];
|
|
133
223
|
const step = RIGHT.includes(key) ? 1 : -1;
|
|
@@ -135,18 +225,27 @@ export function press({ view, confirm, focus }, key, { open, running }) {
|
|
|
135
225
|
}
|
|
136
226
|
if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : strip(view);
|
|
137
227
|
if (key === '0') return strip(0);
|
|
138
|
-
if (
|
|
228
|
+
if (view === 0) {
|
|
229
|
+
if (UP.includes(key)) return { ...strip(0), cursor: Math.max(0, cursor - 1) };
|
|
230
|
+
if (DOWN.includes(key)) return { ...strip(0), cursor: Math.min(count - 1, cursor + 1) };
|
|
231
|
+
if (key === 'r') return open.includes(cursor + 1) ? { ...strip(0), restart: cursor + 1 } : strip(0);
|
|
232
|
+
if (key === 'h') return { ...strip(0), flip: cursor + 1 };
|
|
233
|
+
return strip(0);
|
|
234
|
+
}
|
|
235
|
+
if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor };
|
|
139
236
|
return strip(view);
|
|
140
237
|
}
|
|
141
238
|
|
|
142
|
-
/** Runs the judges, shows the
|
|
239
|
+
/** Runs the judges, shows the overview, and writes the report. Resolves when the engineer quits. */
|
|
143
240
|
export function runTable({ panel, runDir, harness, cwd, target, brief, sources }) {
|
|
144
241
|
const out = process.stdout;
|
|
145
242
|
const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
|
|
146
243
|
const judges = [];
|
|
244
|
+
// The harness of each judge: the run harness, until h flips it.
|
|
245
|
+
const harnesses = criteria.map(() => harness);
|
|
147
246
|
let rows = [];
|
|
148
247
|
let final = null;
|
|
149
|
-
let ui = { view: 0, confirm: false, focus: 'strip' };
|
|
248
|
+
let ui = { view: 0, confirm: false, focus: 'strip', cursor: 0 };
|
|
150
249
|
let stripTimer;
|
|
151
250
|
let lastData = 0;
|
|
152
251
|
const started = Date.now();
|
|
@@ -154,37 +253,45 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
154
253
|
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
155
254
|
// A judge gets the lines above the tab strip.
|
|
156
255
|
const size = () => ({ cols: out.columns, rows: out.rows - STRIP_ROWS });
|
|
256
|
+
const seconds = (from, to = Date.now()) => Math.floor((to - from) / 1000);
|
|
257
|
+
|
|
258
|
+
function screenText(j) {
|
|
259
|
+
const buffer = j.term.buffer.active;
|
|
260
|
+
const lines = [];
|
|
261
|
+
for (let y = 0; y < buffer.length; y++) lines.push(buffer.getLine(y).translateToString(true));
|
|
262
|
+
return lines.join(' ');
|
|
263
|
+
}
|
|
157
264
|
|
|
158
265
|
function state(c, i) {
|
|
266
|
+
const j = judges[i];
|
|
267
|
+
const facts = { harness: harnesses[i], time: j ? seconds(j.started, j.finished) : null, tokens: j?.tokens ?? null, restarted: j?.restartedAt ? seconds(j.restartedAt) : null };
|
|
159
268
|
const file = join(judgeDir(c.rank), 'answer.json');
|
|
160
269
|
if (existsSync(file)) {
|
|
270
|
+
if (j && !j.finished) j.finished = Date.now();
|
|
271
|
+
facts.time = j ? seconds(j.started, j.finished) : null;
|
|
161
272
|
const answer = parseAnswer(readFileSync(file, 'utf8'));
|
|
162
|
-
const errors = check(answer,
|
|
163
|
-
return errors.length ? { state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { state: 'done', result: answer
|
|
273
|
+
const errors = check(answer, c);
|
|
274
|
+
return errors.length ? { ...facts, state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { ...facts, state: 'done', result: answer };
|
|
164
275
|
}
|
|
165
|
-
if (
|
|
166
|
-
if (Date.now() -
|
|
167
|
-
|
|
276
|
+
if (j === undefined) return { ...facts, state: 'queued' };
|
|
277
|
+
if (Date.now() - j.started > MINUTES_PER_CRITERION * 60_000) return { ...facts, state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
|
|
278
|
+
const screen = screenText(j);
|
|
279
|
+
const tokens = tokensOnScreen(screen);
|
|
280
|
+
if (tokens !== null) j.tokens = tokens;
|
|
281
|
+
return { ...facts, tokens: j.tokens ?? null, state: 'running', trust: TRUST_PROMPT.test(screen) };
|
|
168
282
|
}
|
|
169
283
|
|
|
170
|
-
function
|
|
171
|
-
const
|
|
172
|
-
const lines = [];
|
|
173
|
-
for (let y = 0; y < buffer.length; y++) lines.push(buffer.getLine(y).translateToString(true));
|
|
174
|
-
return TRUST_PROMPT.test(lines.join(' '));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function start(rank) {
|
|
178
|
-
const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criteria: [criteria[rank - 1]], harness, cwd, target, brief, sources });
|
|
284
|
+
function start(rank, restartedAt = null) {
|
|
285
|
+
const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target, brief, sources });
|
|
179
286
|
const { cols, rows: lines } = size();
|
|
180
287
|
const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
|
|
181
288
|
const serializer = new SerializeAddon();
|
|
182
289
|
term.loadAddon(serializer);
|
|
183
290
|
const proc = pty.spawn(command, argv, { name: 'xterm-256color', cols, rows: lines, cwd, env: process.env });
|
|
184
|
-
const j = { started: Date.now(), term, serializer, proc, modes: { 25: 'h', 1006: 'l' }, exited: false };
|
|
291
|
+
const j = { started: Date.now(), finished: undefined, restartedAt, tokens: null, term, serializer, proc, modes: { 25: 'h', 1006: 'l' }, exited: false };
|
|
185
292
|
// The write callback keeps the order of the live output and of the screen that showJudge writes.
|
|
186
293
|
proc.onData((data) => term.write(data, () => {
|
|
187
|
-
if (ui.view !== rank) return;
|
|
294
|
+
if (ui.view !== rank || judges[rank - 1] !== j) return;
|
|
188
295
|
out.write(data);
|
|
189
296
|
lastData = Date.now();
|
|
190
297
|
stripSoon();
|
|
@@ -202,10 +309,23 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
202
309
|
judges[rank - 1] = j;
|
|
203
310
|
}
|
|
204
311
|
|
|
312
|
+
// The harness on PATH can be a shell script that runs the real binary as a child and waits for it, so
|
|
313
|
+
// SIGHUP goes to the process group of the judge. The pty makes each judge the leader of its own group.
|
|
314
|
+
const stop = (j) => { if (j && !j.exited) process.kill(-j.proc.pid, 'SIGHUP'); };
|
|
315
|
+
|
|
316
|
+
/** Stops judge rank, drops its answer, and starts it again with its harness. The report is written again later. */
|
|
317
|
+
function restart(rank) {
|
|
318
|
+
stop(judges[rank - 1]);
|
|
319
|
+
const file = join(judgeDir(rank), 'answer.json');
|
|
320
|
+
if (existsSync(file)) unlinkSync(file);
|
|
321
|
+
start(rank, Date.now());
|
|
322
|
+
final = null;
|
|
323
|
+
}
|
|
324
|
+
|
|
205
325
|
// In a judge, the strip hides the cursor while it draws and then puts it back where the judge had it. A cursor
|
|
206
326
|
// past the last column waits for the next character to wrap; a move there would lose that, so the strip waits.
|
|
207
327
|
function drawStrip() {
|
|
208
|
-
const lines = renderStrip({ rows, view: ui.view,
|
|
328
|
+
const lines = renderStrip({ rows, view: ui.view, frame, width: out.columns, focus: ui.focus, elapsed: seconds(started), confirm: ui.confirm });
|
|
209
329
|
const top = out.rows - STRIP_ROWS + 1;
|
|
210
330
|
const strip = lines.map((l, i) => `\x1b[${top + i};1H${l}\x1b[K`).join('');
|
|
211
331
|
if (!ui.view) return void out.write(`${RESET}${strip}`);
|
|
@@ -225,7 +345,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
225
345
|
const j = judges[rank - 1];
|
|
226
346
|
j.term.write('', () => {
|
|
227
347
|
if (ui.view !== rank) return;
|
|
228
|
-
// The
|
|
348
|
+
// The overview already holds the alternate screen, so a judge on its alternate screen gets a clear after
|
|
229
349
|
// the normal screen, which the serializer writes first. The scroll region keeps the judge above the strip.
|
|
230
350
|
const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
|
|
231
351
|
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows - STRIP_ROWS}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
|
|
@@ -234,25 +354,23 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
234
354
|
}
|
|
235
355
|
|
|
236
356
|
function finish() {
|
|
237
|
-
const outcomes = rows.map((r) => (r.state === 'done' ? {
|
|
238
|
-
const report = buildReport({ panel, target,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
242
|
-
final = { status: report.status, path: relative(cwd, reportPath) };
|
|
357
|
+
const outcomes = rows.map((r) => (r.state === 'done' ? { answer: r.result } : { error: r.error }));
|
|
358
|
+
const report = buildReport({ panel, target, outcomes });
|
|
359
|
+
writeFileSync(join(runDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
360
|
+
final = { status: report.status };
|
|
243
361
|
}
|
|
244
362
|
|
|
245
363
|
let frame = 0;
|
|
246
364
|
function draw() {
|
|
247
|
-
const lines =
|
|
365
|
+
const lines = renderOverview({ title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, frame });
|
|
248
366
|
while (lines.length < out.rows - STRIP_ROWS) lines.push('');
|
|
249
367
|
out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
|
|
250
368
|
drawStrip();
|
|
251
369
|
}
|
|
252
370
|
|
|
253
371
|
function tick() {
|
|
372
|
+
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
254
373
|
if (!final) {
|
|
255
|
-
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
256
374
|
const live = rows.filter((r) => r.state === 'running').length;
|
|
257
375
|
const first = judges[0];
|
|
258
376
|
const hold = first && !first.exited && (Date.now() - first.started < FIRST_JUDGE_HEAD_START || rows[0].trust);
|
|
@@ -270,7 +388,6 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
270
388
|
const timer = setInterval(tick, 1000);
|
|
271
389
|
const spinner = setInterval(() => {
|
|
272
390
|
frame += 1;
|
|
273
|
-
if (final) return;
|
|
274
391
|
if (!ui.view) draw();
|
|
275
392
|
else if (Date.now() - lastData > 60) drawStrip();
|
|
276
393
|
}, 100);
|
|
@@ -279,20 +396,24 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
279
396
|
process.stdin.on('data', (data) => {
|
|
280
397
|
const key = data.toString();
|
|
281
398
|
const from = ui.view;
|
|
282
|
-
const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running') });
|
|
283
|
-
ui = { view: next.view, confirm: next.confirm, focus: next.focus };
|
|
399
|
+
const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running'), count: criteria.length });
|
|
400
|
+
ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor };
|
|
284
401
|
if (next.send) {
|
|
285
402
|
judges[from - 1].proc.write(key);
|
|
286
403
|
} else if (next.quit) {
|
|
287
404
|
clearInterval(timer);
|
|
288
405
|
clearInterval(spinner);
|
|
289
|
-
|
|
290
|
-
// SIGHUP goes to the process group of the judge. The pty makes each judge the leader of its own group.
|
|
291
|
-
for (const j of judges) if (j && !j.exited) process.kill(-j.proc.pid, 'SIGHUP');
|
|
406
|
+
for (const j of judges) stop(j);
|
|
292
407
|
out.write(`${TABLE_MODES}\x1b[?25h\x1b[?1049l`);
|
|
293
408
|
process.stdin.setRawMode(false);
|
|
294
409
|
process.stdin.pause();
|
|
295
410
|
done();
|
|
411
|
+
} else if (next.restart) {
|
|
412
|
+
restart(next.restart);
|
|
413
|
+
tick();
|
|
414
|
+
} else if (next.flip) {
|
|
415
|
+
harnesses[next.flip - 1] = harnesses[next.flip - 1] === 'claude' ? 'codex' : 'claude';
|
|
416
|
+
tick();
|
|
296
417
|
} else if (ui.view && ui.view === from) {
|
|
297
418
|
drawStrip();
|
|
298
419
|
} else {
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
You are one judge in a run of a panel. A panel is a named list of criteria. You score one
|
|
1
|
+
You are one judge in a run of a panel. A panel is a named list of criteria. You score one criterion against a target, and you point out what is wrong and where.
|
|
2
2
|
|
|
3
3
|
YOUR INPUTS
|
|
4
4
|
|
|
5
5
|
The user message gives you:
|
|
6
|
-
-
|
|
6
|
+
- Your criterion: its rank, its name, its one-line description, and its body. Follow the body as written.
|
|
7
7
|
- The target: the text that the engineer typed after the panel name, unchanged. It can be a URL, a path, a branch, a pull request, or a sentence.
|
|
8
8
|
- The brief: a file path, or "none supplied".
|
|
9
9
|
- The sources: paths or URLs, or "none".
|
|
@@ -12,80 +12,52 @@ Your working directory is the folder where the engineer ran the command.
|
|
|
12
12
|
|
|
13
13
|
THE TARGET
|
|
14
14
|
|
|
15
|
-
Find out what the target names, and reach it with your tools: read files, run git read commands, open URLs. When you cannot reach the target, or cannot do a test of the body with your tools,
|
|
15
|
+
Find out what the target names, and reach it with your tools: read files, run git read commands, open URLs. When you cannot reach the target, or cannot do a test of the body with your tools, name the test at the end of your verdict after "Not checked:" with the reason. Then score as the body says for that case. Never score what you did not see.
|
|
16
16
|
|
|
17
17
|
ORDER
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Before you read the brief or the sources, do every step that the body puts before them. Keep the records and tables that the body asks for in your own notes; they do not go in the answer.
|
|
20
20
|
|
|
21
21
|
HOW TO SCORE
|
|
22
22
|
|
|
23
|
-
1. Follow the body to a level from 1 to
|
|
24
|
-
2. Apply each cap of the body, such as "cannot support a score above
|
|
25
|
-
3. When the evidence fits two
|
|
26
|
-
4.
|
|
27
|
-
band 1 to 3 = score 1
|
|
28
|
-
band 4 to 6 = score 2
|
|
29
|
-
band 7 or 8 = score 3
|
|
30
|
-
band 9 or 10 = score 4
|
|
31
|
-
5. A criterion passes only at score 4.
|
|
23
|
+
1. Follow the body to a level from 1 to 4: 4 Met, 3 Almost met, 2 Falls short, 1 Fails. The level is the score.
|
|
24
|
+
2. Apply each cap of the body, such as "cannot support a score above 3". When two caps apply, use the lower cap.
|
|
25
|
+
3. When the evidence fits two levels, use the lower level.
|
|
26
|
+
4. A criterion passes only at score 4.
|
|
32
27
|
|
|
33
28
|
EVIDENCE
|
|
34
29
|
|
|
35
|
-
- Each claim needs evidence that the engineer can check: a URL, a file and line, a quote, or a command and its output.
|
|
30
|
+
- Each claim needs evidence that the engineer can check: a URL, a file and line, a quote, or a command and its output. Put the place in brackets inside the verdict, such as (index.html:90).
|
|
36
31
|
- Give no credit for intent. A promise in a comment, a doc, or the brief is not done work.
|
|
37
|
-
- No evidence for a
|
|
38
|
-
- At band 9, name the one specific gap of the body in the evidence.
|
|
32
|
+
- No evidence for a level means the lower level.
|
|
39
33
|
|
|
40
|
-
|
|
34
|
+
THE VERDICT
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
- Below score 4, list each fix that score 4 needs: the problem, the change, and the place.
|
|
36
|
+
One paragraph of 600 characters at most: what holds, then what is wrong and where, each place in brackets, then "Not checked:" and the tests you could not do, when there are any. Judge; do not propose fixes. The fixes are the job of the engineer.
|
|
44
37
|
|
|
45
38
|
PROHIBITIONS
|
|
46
39
|
|
|
47
|
-
- Do not change the body, the
|
|
40
|
+
- Do not change the body, the levels, or the caps.
|
|
48
41
|
- Do not score a fault that the body gives to another criterion.
|
|
49
|
-
- Do not write or change files
|
|
42
|
+
- Do not write or change files, except the answer file when the user message names one.
|
|
43
|
+
- Do not start sub-agents.
|
|
50
44
|
- Do not submit forms, send messages, or post data.
|
|
51
45
|
- Do not read the .panels folder. It holds earlier reports.
|
|
52
46
|
|
|
53
47
|
OUTPUT
|
|
54
48
|
|
|
55
|
-
Return one JSON object and nothing else
|
|
49
|
+
Return one JSON object and nothing else:
|
|
56
50
|
|
|
57
51
|
{
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"band": integer 1 to 10,
|
|
62
|
-
"score": integer 1 to 4,
|
|
63
|
-
"verdict": string, one sentence of 30 words or less,
|
|
64
|
-
"records": [{ "name": string, "columns": [string], "rows": [[string]] }],
|
|
65
|
-
"evidence": [{ "claim": string, "where": string, "quote": string or null }],
|
|
66
|
-
"notChecked": [{ "test": string, "reason": string }],
|
|
67
|
-
"fixes": [{ "problem": string, "change": string, "where": string }]
|
|
68
|
-
}]
|
|
52
|
+
"name": string, the name of your criterion,
|
|
53
|
+
"score": integer 1 to 4,
|
|
54
|
+
"verdict": string, one paragraph, 600 characters at most
|
|
69
55
|
}
|
|
70
56
|
|
|
71
|
-
Example
|
|
57
|
+
Example:
|
|
72
58
|
|
|
73
59
|
{
|
|
74
|
-
"
|
|
75
|
-
"criterion": "Original design assets",
|
|
76
|
-
"band": 5,
|
|
60
|
+
"name": "Original design assets",
|
|
77
61
|
"score": 2,
|
|
78
|
-
"verdict": "The hero shows the real app, and the
|
|
79
|
-
"records": [{
|
|
80
|
-
"name": "asset table",
|
|
81
|
-
"columns": ["asset", "provenance", "product test", "verdict"],
|
|
82
|
-
"rows": [["feature icons (6)", "class \"lucide\"", "generic symbols", "generic"]]
|
|
83
|
-
}],
|
|
84
|
-
"evidence": [{ "claim": "The icons come from Lucide.", "where": "src/components/Features.tsx:3", "quote": "import { Zap } from \"lucide-react\"" }],
|
|
85
|
-
"notChecked": [{ "test": "reverse image search of hero.webp", "reason": "the target is on localhost" }],
|
|
86
|
-
"fixes": [{
|
|
87
|
-
"problem": "Library icons carry the feature grid.",
|
|
88
|
-
"change": "Draw each icon as an object of this product.",
|
|
89
|
-
"where": "src/components/Features.tsx, home section #features"
|
|
90
|
-
}]
|
|
62
|
+
"verdict": "The hero shows the real app, and the cutaway hotel and the team scenes are original and share one idea (public/assets/hotel-world). Every content icon is a generic Lucide symbol (src/components/Features.tsx:3), which caps the band at 6. Not checked: a reverse image search of hero.webp, the target is on localhost."
|
|
91
63
|
}
|
package/schema/report.json
CHANGED
|
@@ -2,123 +2,36 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
|
-
"required": ["
|
|
5
|
+
"required": ["panel", "target", "status", "criteria"],
|
|
6
6
|
"properties": {
|
|
7
|
-
"
|
|
8
|
-
"panel": {
|
|
9
|
-
"type": "object",
|
|
10
|
-
"additionalProperties": false,
|
|
11
|
-
"required": ["owner", "name", "version"],
|
|
12
|
-
"properties": {
|
|
13
|
-
"owner": { "type": "string" },
|
|
14
|
-
"name": { "type": "string" },
|
|
15
|
-
"version": { "type": "integer" }
|
|
16
|
-
}
|
|
17
|
-
},
|
|
7
|
+
"panel": { "type": "string", "description": "owner/name@version" },
|
|
18
8
|
"target": { "type": "string" },
|
|
19
|
-
"
|
|
20
|
-
"harness": { "enum": ["claude", "codex"] },
|
|
21
|
-
"size": { "type": "integer", "minimum": 1 },
|
|
9
|
+
"status": { "enum": ["pass", "fail", "error"] },
|
|
22
10
|
"criteria": {
|
|
23
11
|
"type": "array",
|
|
12
|
+
"description": "One item per criterion of the panel, in panel order. A criterion whose judge failed has score null and the error as its verdict.",
|
|
24
13
|
"items": {
|
|
25
14
|
"type": "object",
|
|
26
15
|
"additionalProperties": false,
|
|
27
|
-
"required": ["
|
|
16
|
+
"required": ["name", "score", "verdict"],
|
|
28
17
|
"properties": {
|
|
29
|
-
"rank": { "type": "integer" },
|
|
30
18
|
"name": { "type": "string" },
|
|
31
19
|
"score": { "type": ["integer", "null"], "minimum": 1, "maximum": 4 },
|
|
32
|
-
"
|
|
33
|
-
"type": "array",
|
|
34
|
-
"items": {
|
|
35
|
-
"type": "object",
|
|
36
|
-
"additionalProperties": false,
|
|
37
|
-
"required": ["judge", "result"],
|
|
38
|
-
"properties": {
|
|
39
|
-
"judge": { "type": "integer" },
|
|
40
|
-
"result": { "$ref": "#/$defs/judgeResult" }
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
"errors": {
|
|
48
|
-
"type": "array",
|
|
49
|
-
"items": {
|
|
50
|
-
"type": "object",
|
|
51
|
-
"additionalProperties": false,
|
|
52
|
-
"required": ["judge", "message"],
|
|
53
|
-
"properties": {
|
|
54
|
-
"judge": { "type": "integer" },
|
|
55
|
-
"message": { "type": "string" }
|
|
20
|
+
"verdict": { "type": "string" }
|
|
56
21
|
}
|
|
57
22
|
}
|
|
58
23
|
}
|
|
59
24
|
},
|
|
60
25
|
"$defs": {
|
|
61
|
-
"
|
|
26
|
+
"criterion": {
|
|
27
|
+
"description": "The answer of one judge: one criterion object. The name is fixed to the criterion of the judge.",
|
|
62
28
|
"type": "object",
|
|
63
29
|
"additionalProperties": false,
|
|
64
|
-
"required": ["
|
|
30
|
+
"required": ["name", "score", "verdict"],
|
|
65
31
|
"properties": {
|
|
66
|
-
"
|
|
67
|
-
"criterion": { "type": "string" },
|
|
68
|
-
"band": { "type": "integer", "minimum": 1, "maximum": 10 },
|
|
32
|
+
"name": { "type": "string" },
|
|
69
33
|
"score": { "type": "integer", "minimum": 1, "maximum": 4 },
|
|
70
|
-
"verdict": { "type": "string" }
|
|
71
|
-
"records": {
|
|
72
|
-
"type": "array",
|
|
73
|
-
"items": {
|
|
74
|
-
"type": "object",
|
|
75
|
-
"additionalProperties": false,
|
|
76
|
-
"required": ["name", "columns", "rows"],
|
|
77
|
-
"properties": {
|
|
78
|
-
"name": { "type": "string" },
|
|
79
|
-
"columns": { "type": "array", "items": { "type": "string" } },
|
|
80
|
-
"rows": { "type": "array", "items": { "type": "array", "items": { "type": "string" } } }
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
"evidence": {
|
|
85
|
-
"type": "array",
|
|
86
|
-
"items": {
|
|
87
|
-
"type": "object",
|
|
88
|
-
"additionalProperties": false,
|
|
89
|
-
"required": ["claim", "where", "quote"],
|
|
90
|
-
"properties": {
|
|
91
|
-
"claim": { "type": "string" },
|
|
92
|
-
"where": { "type": "string" },
|
|
93
|
-
"quote": { "type": ["string", "null"] }
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
"notChecked": {
|
|
98
|
-
"type": "array",
|
|
99
|
-
"items": {
|
|
100
|
-
"type": "object",
|
|
101
|
-
"additionalProperties": false,
|
|
102
|
-
"required": ["test", "reason"],
|
|
103
|
-
"properties": {
|
|
104
|
-
"test": { "type": "string" },
|
|
105
|
-
"reason": { "type": "string" }
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
},
|
|
109
|
-
"fixes": {
|
|
110
|
-
"type": "array",
|
|
111
|
-
"items": {
|
|
112
|
-
"type": "object",
|
|
113
|
-
"additionalProperties": false,
|
|
114
|
-
"required": ["problem", "change", "where"],
|
|
115
|
-
"properties": {
|
|
116
|
-
"problem": { "type": "string" },
|
|
117
|
-
"change": { "type": "string" },
|
|
118
|
-
"where": { "type": "string" }
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
34
|
+
"verdict": { "type": "string", "minLength": 1, "maxLength": 600, "description": "One paragraph: what holds, what is wrong and where (the place in brackets), and \"Not checked: ...\" at the end when something was not checked." }
|
|
122
35
|
}
|
|
123
36
|
}
|
|
124
37
|
}
|