@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 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). One soft wave sweeps across the strip and lights the labels of the running judges as it passes; the same wave sweeps the running rows of the scores table. 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; 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 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,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 { 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';
@@ -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. criteria items are { rank, name, description, body }. Each part sits
14
- * 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.
15
16
  */
16
- export function userMessage(criteria, { target, brief, sources }) {
17
- 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>`];
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: { results }, one judgeResult for each of its criteria, with rank and name fixed. */
23
- export function judgeSchema(criteria) {
24
- const items = criteria.map((c) => ({
25
- ...judgeResult,
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, criteria) {
38
- if (!Array.isArray(answer?.results)) return ['The answer must be a JSON object with a results array.'];
39
- if (answer.results.length !== criteria.length) return [`results must hold ${criteria.length} items, one for each criterion, in rank order.`];
40
- return criteria.flatMap((c, i) => {
41
- const r = answer.results[i];
42
- const missing = judgeResult.required.filter((key) => !(key in Object(r)));
43
- if (missing.length) return [`Item ${i + 1} lacks ${missing.join(', ')}.`];
44
- const errors = [];
45
- if (r.rank !== c.rank || r.criterion !== c.name) errors.push(`Item ${i + 1} must have rank ${c.rank} and criterion "${c.name}".`);
46
- if (!Number.isInteger(r.score) || r.score < 1 || r.score > 4) errors.push(`Rank ${c.rank}: score must be a whole number from 1 to 4.`);
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 { 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.
124
114
  */
125
- 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) {
126
116
  mkdirSync(dir, { recursive: true });
127
- const prompt = userMessage(criteria, { target, brief, sources });
128
- const schema = judgeSchema(criteria);
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: 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 };
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, criteria);
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, criteria);
129
+ errors = check(reply.answer, criterion);
140
130
  }
141
- 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 };
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, criteria, harness, cwd, target, brief, sources }) {
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, 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`;
156
- 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}`;
157
147
  writeFileSync(join(dir, 'prompt.md'), prompt);
158
- writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
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. 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 LEVELS = { 1: 'Fails', 2: 'Falls short', 3: 'Almost met', 4: 'Met' };
19
-
20
- const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
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 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,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 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';
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
- export function render({ title, rows, final, confirm, width, frame = 0 }) {
56
- const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
57
- const pad = Math.max(...rows.map((r) => r.name.length)) + 2;
58
- const lines = [` ${BOLD}panels run${RESET} ${title}`, rule];
59
- for (const r of rows) {
60
- const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
61
- if (r.state === 'running' && r.trust) lines.push(`${ORANGE}${head}press ${r.rank} and answer the trust prompt${RESET}`);
62
- else if (r.state === 'running') lines.push(`${String(r.rank).padStart(3)} ${glimmer(r.name, frame, 5, width)}`);
63
- else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
64
- else if (r.state === 'error') lines.push(`${head}error`);
65
- 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;
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
- lines.push(rule);
68
- if (final) {
69
- const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
70
- lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
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 (confirm) lines.push(' judges still run. q again quits, another key stays');
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 three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
78
- * the elapsed time at the left and the keys of the focus at the right. The first tab is the scores; each judge
79
- * tab reads "N name mark". A running judge has no mark and its label glimmers. The mark is the score (bold at
80
- * 4), ✗ for an error, · while queued, or ! while the harness asks whether to trust the folder. The names step
81
- * down to fit the width: the whole name, the first word, or no name. focus is 'strip' (the active tab is bold
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 renderStrip({ rows, view, final, frame = 0, width, focus = 'strip', elapsed = 0 }) {
85
- const mark = (r) => {
86
- if (r.state === 'running') return r.trust ? '!' : '';
87
- if (r.state === 'queued') return '·';
88
- if (r.state === 'error') return '✗';
89
- 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}`);
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 clock = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
93
- const keys = focus === 'strip' ? '← → tabs · enter back · q quit' : 'ctrl-q tabs';
94
- const tabs = (level) => [
95
- { rank: '', name: final ? `scores ${final.status.toUpperCase()}` : 'scores', mark: '' },
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((t, i) => {
104
- if (i) { line += ' '; col += 2; }
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
- const pieces = [];
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
- const status = ` ${progress}${' '.repeat(Math.max(2, width - progress.length - keys.length - 2))}${keys}`;
123
- 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}`];
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, or quit: true.
128
- * view is 0 for the scores, or N for judge N. open holds the ranks of the judges that have started. With the
129
- * focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
130
- * focus on the strip, the arrows move the active tab and switch the view, 1-9 jump to a started judge, 0 to the
131
- * scores, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (a second q confirms while a judge runs).
132
- * 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.
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 (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 };
147
236
  return strip(view);
148
237
  }
149
238
 
150
- /** 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. */
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, [c]);
171
- 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 };
172
275
  }
173
- if (judges[i] === undefined) return { state: 'queued' };
174
- if (Date.now() - judges[i].started > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
175
- 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) };
176
282
  }
177
283
 
178
- function asksTrust(j) {
179
- const buffer = j.term.buffer.active;
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, 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 });
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 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
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' ? { results: [r.result] } : { error: r.error }));
246
- const report = buildReport({ panel, target, cwd, harness, judges: rows.map((_, i) => [i]), outcomes });
247
- const reportPath = join(runDir, 'report.json');
248
- writeFileSync(join(runDir, 'report.md'), renderMarkdown(report));
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 = 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 });
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
- // The harness on PATH can be a shell script that runs the real binary as a child and waits for it, so
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.6.0",
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,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, 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
 
@@ -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
- RECORDS AND FIXES
34
+ THE VERDICT
35
35
 
36
- - Put each record and table that the body tells you to write in records, with the columns of the body.
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. 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.
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. Give one item in results for each of your criteria, in rank order.
49
+ Return one JSON object and nothing else:
50
50
 
51
51
  {
52
- "results": [{
53
- "rank": integer,
54
- "criterion": string,
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 of one item:
57
+ Example:
65
58
 
66
59
  {
67
- "rank": 5,
68
- "criterion": "Original design assets",
60
+ "name": "Original design assets",
69
61
  "score": 2,
70
- "verdict": "The hero shows the real app, and the six feature icons are Lucide icons.",
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
  }
@@ -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": ["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", "score", "verdict", "records", "evidence", "notChecked", "fixes"],
30
+ "required": ["name", "score", "verdict"],
65
31
  "properties": {
66
- "rank": { "type": "integer" },
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
  }