@xsft/panels 0.6.0 → 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 -42
- package/lib/report.js +11 -37
- package/lib/table.js +220 -107
- package/package.json +1 -1
- package/prompts/judge-system-prompt.txt +16 -36
- package/schema/report.json +11 -97
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,7 +2,8 @@ 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';
|
|
@@ -10,43 +11,32 @@ const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
|
|
|
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
13
|
/**
|
|
13
|
-
* The user message of a judge.
|
|
14
|
-
*
|
|
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.
|
|
15
16
|
*/
|
|
16
|
-
export function userMessage(
|
|
17
|
-
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>`];
|
|
18
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>`);
|
|
19
20
|
return `${parts.join('\n\n')}\n`;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/** The output schema of a judge:
|
|
23
|
-
export function judgeSchema(
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
properties: { ...judgeResult.properties, rank: { type: 'integer', const: c.rank }, criterion: { type: 'string', const: c.name } },
|
|
27
|
-
}));
|
|
28
|
-
return {
|
|
29
|
-
type: 'object',
|
|
30
|
-
additionalProperties: false,
|
|
31
|
-
required: ['results'],
|
|
32
|
-
properties: { results: { type: 'array', minItems: items.length, maxItems: items.length, items: items.length === 1 ? items[0] : { anyOf: items } } },
|
|
33
|
-
};
|
|
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 } } };
|
|
34
27
|
}
|
|
35
28
|
|
|
36
29
|
/** Returns the reasons the answer of a judge is not valid, or an empty array. */
|
|
37
|
-
export function check(answer,
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
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.`);
|
|
48
|
-
return errors;
|
|
49
|
-
});
|
|
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;
|
|
50
40
|
}
|
|
51
41
|
|
|
52
42
|
/** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
|
|
@@ -120,25 +110,25 @@ export function parseAnswer(text) {
|
|
|
120
110
|
|
|
121
111
|
/**
|
|
122
112
|
* Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
|
|
123
|
-
* Returns {
|
|
113
|
+
* Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
|
|
124
114
|
*/
|
|
125
|
-
export async function runJudge({ dir,
|
|
115
|
+
export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources }, command = harness) {
|
|
126
116
|
mkdirSync(dir, { recursive: true });
|
|
127
|
-
const prompt = userMessage(
|
|
128
|
-
const schema = judgeSchema(
|
|
117
|
+
const prompt = userMessage(criterion, { target, brief, sources });
|
|
118
|
+
const schema = judgeSchema(criterion);
|
|
129
119
|
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
130
120
|
writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
|
|
131
|
-
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 };
|
|
132
122
|
const ask = harness === 'claude' ? askClaude : askCodex;
|
|
133
123
|
let reply = await ask(j, prompt);
|
|
134
124
|
if (reply.error) return reply;
|
|
135
|
-
let errors = check(reply.answer,
|
|
125
|
+
let errors = check(reply.answer, criterion);
|
|
136
126
|
if (errors.length) {
|
|
137
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);
|
|
138
128
|
if (reply.error) return reply;
|
|
139
|
-
errors = check(reply.answer,
|
|
129
|
+
errors = check(reply.answer, criterion);
|
|
140
130
|
}
|
|
141
|
-
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 };
|
|
142
132
|
}
|
|
143
133
|
|
|
144
134
|
const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
|
|
@@ -150,12 +140,12 @@ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOM
|
|
|
150
140
|
* Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
|
|
151
141
|
* arguments that start the judge in its interactive harness.
|
|
152
142
|
*/
|
|
153
|
-
export function writeJudge({ dir,
|
|
143
|
+
export function writeJudge({ dir, criterion, harness, cwd, target, brief, sources }) {
|
|
154
144
|
mkdirSync(dir, { recursive: true });
|
|
155
|
-
const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer JSON,
|
|
156
|
-
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}`;
|
|
157
147
|
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
158
|
-
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(
|
|
148
|
+
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criterion), null, 2));
|
|
159
149
|
if (harness === 'codex') {
|
|
160
150
|
// Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
|
|
161
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,45 +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
|
-
|
|
22
|
-
export function renderMarkdown(report) {
|
|
23
|
-
const { status, panel, target, criteria, errors } = report;
|
|
24
|
-
const lines = [
|
|
25
|
-
`${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)`,
|
|
26
|
-
'',
|
|
27
|
-
'| # | Criterion | Score | Verdict |',
|
|
28
|
-
'|---|---|---|---|',
|
|
29
|
-
];
|
|
30
|
-
for (const c of criteria) {
|
|
31
|
-
const lowest = c.judges.find((a) => a.result.score === c.score);
|
|
32
|
-
lines.push(`| ${c.rank} | ${cell(c.name)} | ${c.score ?? 'error'} | ${cell(lowest?.result.verdict ?? '')} |`);
|
|
33
|
-
}
|
|
34
|
-
const list = (title, items) => items.length && lines.push(title, ...items.map((item) => `- ${item}`));
|
|
35
|
-
for (const c of criteria) {
|
|
36
|
-
for (const { result } of c.judges) {
|
|
37
|
-
lines.push('', `## ${c.rank}. ${c.name}: ${result.score}/4 ${LEVELS[result.score]}`);
|
|
38
|
-
list('Evidence', result.evidence.map((e) => `${e.where}: ${e.quote ?? e.claim}`));
|
|
39
|
-
list('Fixes', result.fixes.map((f) => `${f.problem} ${f.change} (${f.where})`));
|
|
40
|
-
list('Not checked', result.notChecked.map((n) => `${n.test}: ${n.reason}`));
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
if (errors.length) lines.push('', '## Errors', ...errors.map((e) => `- judge ${e.judge}: ${e.message}`));
|
|
44
|
-
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}`, '']);
|
|
45
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,26 +17,29 @@ 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';
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The lines of the table. rows hold { rank, name, state, result, trust }, and state is queued, running, done, or error.
|
|
31
|
-
* trust is true while the harness of a running judge asks whether to trust the folder.
|
|
32
|
-
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
33
|
-
*/
|
|
34
33
|
// The glimmer on the text of the running judges: grey text, and one soft wave three columns wide that moves one
|
|
35
34
|
// column per frame from the left edge of the screen to the right edge and starts over. A running label lights
|
|
36
35
|
// up only where the wave passes over its own columns, so every running label lights at the same columns.
|
|
37
36
|
const GLIMMER = { base: '\x1b[38;5;244m', mid: '\x1b[38;5;247m', high: '\x1b[38;5;250m' };
|
|
38
37
|
const GLIMMER_ORANGE = { base: ORANGE, mid: '\x1b[38;2;255;140;100m', high: '\x1b[38;2;255;175;145m' };
|
|
39
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;
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* text with the glimmer at frame, where the text starts at the screen column start (0-based) on a screen width
|
|
@@ -49,62 +52,141 @@ export function glimmer(text, frame, start, width, colours = GLIMMER, prefix = '
|
|
|
49
52
|
return `${prefix}${d === 0 ? colours.high : d === 1 ? colours.mid : colours.base}${c}`;
|
|
50
53
|
}).join('') + RESET;
|
|
51
54
|
}
|
|
52
|
-
// The tab strip takes the last three lines of the terminal: a hairline, the tabs, then the status and the keys.
|
|
53
|
-
const STRIP_ROWS = 3;
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
74
|
+
}
|
|
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);
|
|
66
90
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
71
101
|
}
|
|
72
|
-
if (
|
|
102
|
+
if (line) lines.push(line);
|
|
73
103
|
return lines;
|
|
74
104
|
}
|
|
75
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
|
+
|
|
76
109
|
/**
|
|
77
|
-
* The
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* 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.
|
|
83
115
|
*/
|
|
84
|
-
export function
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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}`);
|
|
90
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 }) {
|
|
91
180
|
const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
...rows.map((r) => ({ rank: String(r.rank), name: level === 0 ? r.name : level === 1 ? r.name.split(' ')[0] : '', mark: mark(r) })),
|
|
97
|
-
];
|
|
98
|
-
const plain = (t) => [t.rank, t.name, t.mark].filter(Boolean).join(' ');
|
|
99
|
-
const used = (list) => 1 + list.reduce((n, t) => n + plain(t).length, 0) + 2 * (list.length - 1);
|
|
100
|
-
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);
|
|
101
185
|
let line = ' ';
|
|
102
186
|
let col = 1;
|
|
103
|
-
list.forEach((
|
|
104
|
-
if (i) { line +=
|
|
187
|
+
list.forEach((text, i) => {
|
|
188
|
+
if (i) { line += `${GREY} │ ${RESET}`; col += 3; }
|
|
105
189
|
const active = i === view;
|
|
106
|
-
const base = active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY;
|
|
107
|
-
const text = plain(t);
|
|
108
190
|
const row = rows[i - 1];
|
|
109
191
|
const start = col;
|
|
110
192
|
col += text.length;
|
|
@@ -112,30 +194,30 @@ export function renderStrip({ rows, view, final, frame = 0, width, focus = 'stri
|
|
|
112
194
|
line += glimmer(text, frame, start, width, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
|
|
113
195
|
return;
|
|
114
196
|
}
|
|
115
|
-
|
|
116
|
-
if (t.rank) pieces.push(`${base}${t.rank}${RESET}`);
|
|
117
|
-
if (t.name) pieces.push(`${base}${t.name}${RESET}`);
|
|
118
|
-
if (t.mark) pieces.push(`${row.state === 'done' && row.result.score === 4 ? `${base}${BOLD}` : base}${t.mark}${RESET}`);
|
|
119
|
-
line += pieces.join(' ');
|
|
197
|
+
line += `${active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY}${text}${RESET}`;
|
|
120
198
|
});
|
|
121
|
-
const progress = `${finished} of ${rows.length} done · ${clock}`;
|
|
122
|
-
|
|
123
|
-
|
|
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}`];
|
|
124
204
|
}
|
|
125
205
|
|
|
126
206
|
/**
|
|
127
|
-
* The next { view, confirm, focus } after one key, plus send: true when the key goes to the judge,
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* focus on
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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.
|
|
133
215
|
*/
|
|
134
|
-
export function press({ view, confirm, focus }, key, { open, running }) {
|
|
135
|
-
const strip = (v) => ({ view: v, confirm: false, focus: 'strip' });
|
|
136
|
-
if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, send: 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 };
|
|
137
219
|
if (confirm) return { ...strip(0), quit: key === 'q' };
|
|
138
|
-
if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip' } : { ...strip(0), quit: true };
|
|
220
|
+
if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor } : { ...strip(0), quit: true };
|
|
139
221
|
if (RIGHT.includes(key) || LEFT.includes(key)) {
|
|
140
222
|
const cycle = [0, ...open];
|
|
141
223
|
const step = RIGHT.includes(key) ? 1 : -1;
|
|
@@ -143,18 +225,27 @@ export function press({ view, confirm, focus }, key, { open, running }) {
|
|
|
143
225
|
}
|
|
144
226
|
if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : strip(view);
|
|
145
227
|
if (key === '0') return strip(0);
|
|
146
|
-
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 };
|
|
147
236
|
return strip(view);
|
|
148
237
|
}
|
|
149
238
|
|
|
150
|
-
/** Runs the judges, shows the
|
|
239
|
+
/** Runs the judges, shows the overview, and writes the report. Resolves when the engineer quits. */
|
|
151
240
|
export function runTable({ panel, runDir, harness, cwd, target, brief, sources }) {
|
|
152
241
|
const out = process.stdout;
|
|
153
242
|
const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
|
|
154
243
|
const judges = [];
|
|
244
|
+
// The harness of each judge: the run harness, until h flips it.
|
|
245
|
+
const harnesses = criteria.map(() => harness);
|
|
155
246
|
let rows = [];
|
|
156
247
|
let final = null;
|
|
157
|
-
let ui = { view: 0, confirm: false, focus: 'strip' };
|
|
248
|
+
let ui = { view: 0, confirm: false, focus: 'strip', cursor: 0 };
|
|
158
249
|
let stripTimer;
|
|
159
250
|
let lastData = 0;
|
|
160
251
|
const started = Date.now();
|
|
@@ -162,37 +253,45 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
162
253
|
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
163
254
|
// A judge gets the lines above the tab strip.
|
|
164
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
|
+
}
|
|
165
264
|
|
|
166
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 };
|
|
167
268
|
const file = join(judgeDir(c.rank), 'answer.json');
|
|
168
269
|
if (existsSync(file)) {
|
|
270
|
+
if (j && !j.finished) j.finished = Date.now();
|
|
271
|
+
facts.time = j ? seconds(j.started, j.finished) : null;
|
|
169
272
|
const answer = parseAnswer(readFileSync(file, 'utf8'));
|
|
170
|
-
const errors = check(answer,
|
|
171
|
-
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 };
|
|
172
275
|
}
|
|
173
|
-
if (
|
|
174
|
-
if (Date.now() -
|
|
175
|
-
|
|
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) };
|
|
176
282
|
}
|
|
177
283
|
|
|
178
|
-
function
|
|
179
|
-
const
|
|
180
|
-
const lines = [];
|
|
181
|
-
for (let y = 0; y < buffer.length; y++) lines.push(buffer.getLine(y).translateToString(true));
|
|
182
|
-
return TRUST_PROMPT.test(lines.join(' '));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function start(rank) {
|
|
186
|
-
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 });
|
|
187
286
|
const { cols, rows: lines } = size();
|
|
188
287
|
const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
|
|
189
288
|
const serializer = new SerializeAddon();
|
|
190
289
|
term.loadAddon(serializer);
|
|
191
290
|
const proc = pty.spawn(command, argv, { name: 'xterm-256color', cols, rows: lines, cwd, env: process.env });
|
|
192
|
-
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 };
|
|
193
292
|
// The write callback keeps the order of the live output and of the screen that showJudge writes.
|
|
194
293
|
proc.onData((data) => term.write(data, () => {
|
|
195
|
-
if (ui.view !== rank) return;
|
|
294
|
+
if (ui.view !== rank || judges[rank - 1] !== j) return;
|
|
196
295
|
out.write(data);
|
|
197
296
|
lastData = Date.now();
|
|
198
297
|
stripSoon();
|
|
@@ -210,10 +309,23 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
210
309
|
judges[rank - 1] = j;
|
|
211
310
|
}
|
|
212
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
|
+
|
|
213
325
|
// In a judge, the strip hides the cursor while it draws and then puts it back where the judge had it. A cursor
|
|
214
326
|
// past the last column waits for the next character to wrap; a move there would lose that, so the strip waits.
|
|
215
327
|
function drawStrip() {
|
|
216
|
-
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 });
|
|
217
329
|
const top = out.rows - STRIP_ROWS + 1;
|
|
218
330
|
const strip = lines.map((l, i) => `\x1b[${top + i};1H${l}\x1b[K`).join('');
|
|
219
331
|
if (!ui.view) return void out.write(`${RESET}${strip}`);
|
|
@@ -233,7 +345,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
233
345
|
const j = judges[rank - 1];
|
|
234
346
|
j.term.write('', () => {
|
|
235
347
|
if (ui.view !== rank) return;
|
|
236
|
-
// The
|
|
348
|
+
// The overview already holds the alternate screen, so a judge on its alternate screen gets a clear after
|
|
237
349
|
// the normal screen, which the serializer writes first. The scroll region keeps the judge above the strip.
|
|
238
350
|
const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
|
|
239
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('')}`);
|
|
@@ -242,25 +354,23 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
242
354
|
}
|
|
243
355
|
|
|
244
356
|
function finish() {
|
|
245
|
-
const outcomes = rows.map((r) => (r.state === 'done' ? {
|
|
246
|
-
const report = buildReport({ panel, target,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
250
|
-
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 };
|
|
251
361
|
}
|
|
252
362
|
|
|
253
363
|
let frame = 0;
|
|
254
364
|
function draw() {
|
|
255
|
-
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 });
|
|
256
366
|
while (lines.length < out.rows - STRIP_ROWS) lines.push('');
|
|
257
367
|
out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
|
|
258
368
|
drawStrip();
|
|
259
369
|
}
|
|
260
370
|
|
|
261
371
|
function tick() {
|
|
372
|
+
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
262
373
|
if (!final) {
|
|
263
|
-
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
264
374
|
const live = rows.filter((r) => r.state === 'running').length;
|
|
265
375
|
const first = judges[0];
|
|
266
376
|
const hold = first && !first.exited && (Date.now() - first.started < FIRST_JUDGE_HEAD_START || rows[0].trust);
|
|
@@ -278,7 +388,6 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
278
388
|
const timer = setInterval(tick, 1000);
|
|
279
389
|
const spinner = setInterval(() => {
|
|
280
390
|
frame += 1;
|
|
281
|
-
if (final) return;
|
|
282
391
|
if (!ui.view) draw();
|
|
283
392
|
else if (Date.now() - lastData > 60) drawStrip();
|
|
284
393
|
}, 100);
|
|
@@ -287,20 +396,24 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
287
396
|
process.stdin.on('data', (data) => {
|
|
288
397
|
const key = data.toString();
|
|
289
398
|
const from = ui.view;
|
|
290
|
-
const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running') });
|
|
291
|
-
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 };
|
|
292
401
|
if (next.send) {
|
|
293
402
|
judges[from - 1].proc.write(key);
|
|
294
403
|
} else if (next.quit) {
|
|
295
404
|
clearInterval(timer);
|
|
296
405
|
clearInterval(spinner);
|
|
297
|
-
|
|
298
|
-
// SIGHUP goes to the process group of the judge. The pty makes each judge the leader of its own group.
|
|
299
|
-
for (const j of judges) if (j && !j.exited) process.kill(-j.proc.pid, 'SIGHUP');
|
|
406
|
+
for (const j of judges) stop(j);
|
|
300
407
|
out.write(`${TABLE_MODES}\x1b[?25h\x1b[?1049l`);
|
|
301
408
|
process.stdin.setRawMode(false);
|
|
302
409
|
process.stdin.pause();
|
|
303
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();
|
|
304
417
|
} else if (ui.view && ui.view === from) {
|
|
305
418
|
drawStrip();
|
|
306
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,11 +12,11 @@ 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
|
|
|
@@ -27,57 +27,37 @@ HOW TO SCORE
|
|
|
27
27
|
|
|
28
28
|
EVIDENCE
|
|
29
29
|
|
|
30
|
-
- 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).
|
|
31
31
|
- Give no credit for intent. A promise in a comment, a doc, or the brief is not done work.
|
|
32
32
|
- No evidence for a level means the lower level.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
THE VERDICT
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
- 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.
|
|
38
37
|
|
|
39
38
|
PROHIBITIONS
|
|
40
39
|
|
|
41
40
|
- Do not change the body, the levels, or the caps.
|
|
42
41
|
- Do not score a fault that the body gives to another criterion.
|
|
43
|
-
- 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.
|
|
44
44
|
- Do not submit forms, send messages, or post data.
|
|
45
45
|
- Do not read the .panels folder. It holds earlier reports.
|
|
46
46
|
|
|
47
47
|
OUTPUT
|
|
48
48
|
|
|
49
|
-
Return one JSON object and nothing else
|
|
49
|
+
Return one JSON object and nothing else:
|
|
50
50
|
|
|
51
51
|
{
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"score": integer 1 to 4,
|
|
56
|
-
"verdict": string, one sentence of 30 words or less,
|
|
57
|
-
"records": [{ "name": string, "columns": [string], "rows": [[string]] }],
|
|
58
|
-
"evidence": [{ "claim": string, "where": string, "quote": string or null }],
|
|
59
|
-
"notChecked": [{ "test": string, "reason": string }],
|
|
60
|
-
"fixes": [{ "problem": string, "change": string, "where": string }]
|
|
61
|
-
}]
|
|
52
|
+
"name": string, the name of your criterion,
|
|
53
|
+
"score": integer 1 to 4,
|
|
54
|
+
"verdict": string, one paragraph, 600 characters at most
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
Example
|
|
57
|
+
Example:
|
|
65
58
|
|
|
66
59
|
{
|
|
67
|
-
"
|
|
68
|
-
"criterion": "Original design assets",
|
|
60
|
+
"name": "Original design assets",
|
|
69
61
|
"score": 2,
|
|
70
|
-
"verdict": "The hero shows the real app, and the
|
|
71
|
-
"records": [{
|
|
72
|
-
"name": "asset table",
|
|
73
|
-
"columns": ["asset", "provenance", "product test", "verdict"],
|
|
74
|
-
"rows": [["feature icons (6)", "class \"lucide\"", "generic symbols", "generic"]]
|
|
75
|
-
}],
|
|
76
|
-
"evidence": [{ "claim": "The icons come from Lucide.", "where": "src/components/Features.tsx:3", "quote": "import { Zap } from \"lucide-react\"" }],
|
|
77
|
-
"notChecked": [{ "test": "reverse image search of hero.webp", "reason": "the target is on localhost" }],
|
|
78
|
-
"fixes": [{
|
|
79
|
-
"problem": "Library icons carry the feature grid.",
|
|
80
|
-
"change": "Draw each icon as an object of this product.",
|
|
81
|
-
"where": "src/components/Features.tsx, home section #features"
|
|
82
|
-
}]
|
|
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."
|
|
83
63
|
}
|
package/schema/report.json
CHANGED
|
@@ -2,122 +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" },
|
|
32
|
+
"name": { "type": "string" },
|
|
68
33
|
"score": { "type": "integer", "minimum": 1, "maximum": 4 },
|
|
69
|
-
"verdict": { "type": "string" }
|
|
70
|
-
"records": {
|
|
71
|
-
"type": "array",
|
|
72
|
-
"items": {
|
|
73
|
-
"type": "object",
|
|
74
|
-
"additionalProperties": false,
|
|
75
|
-
"required": ["name", "columns", "rows"],
|
|
76
|
-
"properties": {
|
|
77
|
-
"name": { "type": "string" },
|
|
78
|
-
"columns": { "type": "array", "items": { "type": "string" } },
|
|
79
|
-
"rows": { "type": "array", "items": { "type": "array", "items": { "type": "string" } } }
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
"evidence": {
|
|
84
|
-
"type": "array",
|
|
85
|
-
"items": {
|
|
86
|
-
"type": "object",
|
|
87
|
-
"additionalProperties": false,
|
|
88
|
-
"required": ["claim", "where", "quote"],
|
|
89
|
-
"properties": {
|
|
90
|
-
"claim": { "type": "string" },
|
|
91
|
-
"where": { "type": "string" },
|
|
92
|
-
"quote": { "type": ["string", "null"] }
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
"notChecked": {
|
|
97
|
-
"type": "array",
|
|
98
|
-
"items": {
|
|
99
|
-
"type": "object",
|
|
100
|
-
"additionalProperties": false,
|
|
101
|
-
"required": ["test", "reason"],
|
|
102
|
-
"properties": {
|
|
103
|
-
"test": { "type": "string" },
|
|
104
|
-
"reason": { "type": "string" }
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
"fixes": {
|
|
109
|
-
"type": "array",
|
|
110
|
-
"items": {
|
|
111
|
-
"type": "object",
|
|
112
|
-
"additionalProperties": false,
|
|
113
|
-
"required": ["problem", "change", "where"],
|
|
114
|
-
"properties": {
|
|
115
|
-
"problem": { "type": "string" },
|
|
116
|
-
"change": { "type": "string" },
|
|
117
|
-
"where": { "type": "string" }
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
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." }
|
|
121
35
|
}
|
|
122
36
|
}
|
|
123
37
|
}
|