@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 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 score table, 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 table 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`).
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
- A tab strip on the last three lines shows a hairline, a tab per view, and a status line with the progress, the elapsed time, and the keys. The tabs are the scores, then each judge with its number, name, and mark (score, ✗ for an error, · while queued, ! while the harness asks whether to trust the folder). The label of a running judge glimmers. 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, `0` to the scores, `enter` gives the focus to the judge, and `q` quits and asks `y` while a judge still runs. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The scores take no input, 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.
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.md` and `report.json`: the scores, the evidence, the fixes, and the errors. `schema/report.json` is the schema of `report.json`.
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, renderMarkdown } from '../lib/report.js';
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(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
72
- dir: join(runDir, 'judges', String(j + 1)),
73
- criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
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, cwd, harness, judges, outcomes });
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(markdown);
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 { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
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. criteria items are { rank, name, description, body }. Each part sits
16
- * in a tag, because a body is markdown with headings of its own.
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(criteria, { target, brief, sources }) {
19
- const parts = criteria.map((c) => `<criterion rank="${c.rank}" name="${c.name}">\nDescription: ${c.description}\n\n${c.body}\n</criterion>`);
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: { results }, one judgeResult for each of its criteria, with rank and name fixed. */
25
- export function judgeSchema(criteria) {
26
- const items = criteria.map((c) => ({
27
- ...judgeResult,
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, criteria) {
40
- if (!Array.isArray(answer?.results)) return ['The answer must be a JSON object with a results array.'];
41
- if (answer.results.length !== criteria.length) return [`results must hold ${criteria.length} items, one for each criterion, in rank order.`];
42
- return criteria.flatMap((c, i) => {
43
- const r = answer.results[i];
44
- const missing = judgeResult.required.filter((key) => !(key in Object(r)));
45
- if (missing.length) return [`Item ${i + 1} lacks ${missing.join(', ')}.`];
46
- const errors = [];
47
- if (r.rank !== c.rank || r.criterion !== c.name) errors.push(`Item ${i + 1} must have rank ${c.rank} and criterion "${c.name}".`);
48
- if (!Number.isInteger(r.band) || r.band < 1 || r.band > 10) errors.push(`Rank ${c.rank}: band must be a whole number from 1 to 10.`);
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 { results } or { error }. command is the harness binary; the tests pass a stub.
113
+ * Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
127
114
  */
128
- export async function runJudge({ dir, criteria, harness, cwd, target, brief, sources }, command = harness) {
115
+ export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources }, command = harness) {
129
116
  mkdirSync(dir, { recursive: true });
130
- const prompt = userMessage(criteria, { target, brief, sources });
131
- const schema = judgeSchema(criteria);
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: criteria.length * MINUTES_PER_CRITERION * 60_000 };
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, criteria);
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, criteria);
129
+ errors = check(reply.answer, criterion);
143
130
  }
144
- return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
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, criteria, harness, cwd, target, brief, sources }) {
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, which 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`;
159
- const prompt = `${userMessage(criteria, { target, brief, sources })}\n${closing}`;
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(criteria), null, 2));
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. judges holds the criterion indexes of each judge, and outcomes the
3
- * { results } or { error } of each judge, in the same order. The lowest score of a criterion
4
- * counts, and a criterion of a failed judge gets score null.
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, cwd, harness, judges, outcomes }) {
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 mine = answers.filter((a) => a.i === i).map(({ judge, result }) => ({ judge, result }));
12
- return { rank: i + 1, name: c.name, score: failed.has(i) ? null : Math.min(...mine.map((a) => a.result.score)), judges: mine };
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 { status, panel: { owner: panel.owner, name: panel.name, version: panel.version }, target, cwd, harness, size: judges.length, criteria, errors };
13
+ return { panel: `${panel.owner}/${panel.name}@${panel.version}`, target, status, criteria };
16
14
  }
17
15
 
18
- const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
19
-
20
- export function renderMarkdown(report) {
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 score table, and a view of each judge. Each judge runs its interactive
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, relative } from 'node:path';
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, renderMarkdown } from './report.js';
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 table gets the keys and the screen as before.
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
- * 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.
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
- // The glimmer on the text of a running judge: grey text, and a bright highlight three characters wide that
35
- // sweeps from left to right one character per frame and wraps.
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.min(Math.abs(i - centre), text.length - Math.abs(i - centre));
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
- export function render({ title, rows, final, confirm, width, frame = 0 }) {
51
- const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
52
- const pad = Math.max(...rows.map((r) => r.name.length)) + 2;
53
- const lines = [` ${BOLD}panels run${RESET} ${title}`, rule];
54
- for (const r of rows) {
55
- const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
56
- if (r.state === 'running' && r.trust) lines.push(`${ORANGE}${head}press ${r.rank} and answer the trust prompt${RESET}`);
57
- else if (r.state === 'running') lines.push(`${String(r.rank).padStart(3)} ${glimmer(r.name, frame)}`);
58
- else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
59
- else if (r.state === 'error') lines.push(`${head}error`);
60
- else lines.push(`${head}${r.result.score}/4`);
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
- lines.push(rule);
63
- if (final) {
64
- const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
65
- lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
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 (confirm) lines.push(' judges still run. y quits, another key stays');
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 three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
73
- * the elapsed time at the left and the keys of the focus at the right. The first tab is the scores; each judge
74
- * tab reads "N name mark". A running judge has no mark and its label glimmers. The mark is the score (bold at
75
- * 4), ✗ for an error, · while queued, or ! while the harness asks whether to trust the folder. The names step
76
- * down to fit the width: the whole name, the first word, or no name. focus is 'strip' (the active tab is bold
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 renderStrip({ rows, view, final, frame = 0, width, focus = 'strip', elapsed = 0 }) {
80
- const mark = (r) => {
81
- if (r.state === 'running') return r.trust ? '!' : '';
82
- if (r.state === 'queued') return '·';
83
- if (r.state === 'error') return '✗';
84
- return `${r.result.score}/4`;
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 clock = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
88
- const keys = focus === 'strip' ? '← → tabs · enter back · q quit' : 'ctrl-q tabs';
89
- const tabs = (level) => [
90
- { rank: '', name: final ? `scores ${final.status.toUpperCase()}` : 'scores', mark: '' },
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
- list.forEach((t, i) => {
98
- if (i) line += ' ';
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
- const pieces = [];
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
- const status = ` ${progress}${' '.repeat(Math.max(2, width - progress.length - keys.length - 2))}${keys}`;
115
- return [`${GREY}${'─'.repeat(width)}${RESET}`, line, `${GREY}${status}${RESET}`];
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, or quit: true.
120
- * view is 0 for the scores, or N for judge N. open holds the ranks of the judges that have started. With the
121
- * focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
122
- * focus on the strip, the arrows move the active tab and switch the view, 1-9 jump to a started judge, 0 to the
123
- * scores, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (y confirms while a judge runs).
124
- * The scores take no input, so the strip keeps the focus there.
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 === 'y' };
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 (BACK.includes(key) && view) return { view, confirm: false, focus: 'judge' };
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 table, and writes the report. Resolves when the engineer quits. */
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, [c]);
163
- return errors.length ? { state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { state: 'done', result: answer.results[0] };
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 (judges[i] === undefined) return { state: 'queued' };
166
- if (Date.now() - judges[i].started > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
167
- return { state: 'running', trust: asksTrust(judges[i]) };
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 asksTrust(j) {
171
- const buffer = j.term.buffer.active;
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, final, frame, width: out.columns, focus: ui.focus, elapsed: Math.floor((Date.now() - started) / 1000) });
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 table already holds the alternate screen, so a judge on its alternate screen gets a clear after
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' ? { results: [r.result] } : { error: r.error }));
238
- const report = buildReport({ panel, target, cwd, harness, judges: rows.map((_, i) => [i]), outcomes });
239
- const reportPath = join(runDir, 'report.json');
240
- writeFileSync(join(runDir, 'report.md'), renderMarkdown(report));
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 = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm: ui.confirm, width: Math.min(out.columns, 100), frame });
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
- // The harness on PATH can be a shell script that runs the real binary as a child and waits for it, so
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
6
  "type": "module",
@@ -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 or more criteria against a target, and you name the fixes for each criterion that does not pass.
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
- - Each criterion: its rank, its name, its one-line description, and its body. Follow the body as written.
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, add the test to notChecked with the reason. Then score as the body says for that case. Never score what you did not see.
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
- Score your criteria one at a time, in rank order. Before you read the brief or the sources, do every step that one of your bodies puts before them.
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 10. The body calls this level a score. Here it is the band.
24
- 2. Apply each cap of the body, such as "cannot support a score above 8". When two caps apply, use the lower cap.
25
- 3. When the evidence fits two bands, use the lower band.
26
- 4. Convert the band to the score with this map:
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 band means the lower band.
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
- RECORDS AND FIXES
34
+ THE VERDICT
41
35
 
42
- - Put each record and table that the body tells you to write in records, with the columns of the body.
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 bands, the caps, or the map.
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. Name each fix, and do not make it.
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. Give one item in results for each of your criteria, in rank order.
49
+ Return one JSON object and nothing else:
56
50
 
57
51
  {
58
- "results": [{
59
- "rank": integer,
60
- "criterion": string,
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 of one item:
57
+ Example:
72
58
 
73
59
  {
74
- "rank": 5,
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 six feature icons are Lucide icons.",
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
  }
@@ -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": ["status", "panel", "target", "cwd", "harness", "size", "criteria", "errors"],
5
+ "required": ["panel", "target", "status", "criteria"],
6
6
  "properties": {
7
- "status": { "enum": ["pass", "fail", "error"] },
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
- "cwd": { "type": "string" },
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": ["rank", "name", "score", "judges"],
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
- "judges": {
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
- "judgeResult": {
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": ["rank", "criterion", "band", "score", "verdict", "records", "evidence", "notChecked", "fixes"],
30
+ "required": ["name", "score", "verdict"],
65
31
  "properties": {
66
- "rank": { "type": "integer" },
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
  }