@xsft/panels 0.1.0 → 0.3.1
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 +10 -5
- package/bin/panels.js +18 -5
- package/lib/args.js +0 -4
- package/lib/judge.js +28 -2
- package/lib/table.js +200 -0
- package/package.json +7 -2
- package/lib/split.js +0 -14
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @xsft/panels
|
|
2
2
|
|
|
3
|
-
`panels run` runs a panel from cross.software/panels against a target. Each judge is a `claude` or `codex` session that scores one
|
|
3
|
+
`panels run` runs a panel from cross.software/panels against a target. Each judge is a `claude` or `codex` session that scores one criterion of the panel. You need Node 20 or newer and one of the two harnesses.
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
6
|
panels run <panel> [target...] [flags]
|
|
@@ -8,7 +8,6 @@ panels run <panel> [target...] [flags]
|
|
|
8
8
|
<panel> owner/name, or owner/name/vN to pin version N
|
|
9
9
|
[target...] free words, joined with single spaces into one target
|
|
10
10
|
|
|
11
|
-
--size <n> number of judges default: the number of criteria
|
|
12
11
|
--harness <name> claude | codex default: claude if installed, else codex
|
|
13
12
|
--brief <file> the brief default: none
|
|
14
13
|
--sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
|
|
@@ -26,7 +25,13 @@ npx @xsft/panels run xsft/marketing-site https://example.com --brief docs/brief.
|
|
|
26
25
|
npx @xsft/panels run xsft/marketing-site/v6 . at http://localhost:4321 --sources docs --harness codex
|
|
27
26
|
```
|
|
28
27
|
|
|
29
|
-
Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes
|
|
28
|
+
Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes.
|
|
29
|
+
|
|
30
|
+
## Terminal mode
|
|
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`).
|
|
33
|
+
|
|
34
|
+
Keys in the table: `1` to `9` open the screen of that judge, and `q` quits and asks `y` while a judge still runs. In a judge, each key goes to the judge, and `ctrl-]` returns to the table.
|
|
30
35
|
|
|
31
36
|
## Run folder
|
|
32
37
|
|
|
@@ -34,7 +39,7 @@ Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
|
|
|
34
39
|
|
|
35
40
|
- `panel.json`: the panel as the api gave it.
|
|
36
41
|
- `report.md` and `report.json`: the scores, the evidence, the fixes, and the errors. `schema/report.json` is the schema of `report.json`.
|
|
37
|
-
- `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k.
|
|
42
|
+
- `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.
|
|
38
43
|
|
|
39
44
|
The last line on stdout is the path of `report.json`.
|
|
40
45
|
|
|
@@ -48,4 +53,4 @@ The last line on stdout is the path of `report.json`.
|
|
|
48
53
|
- `--model`: each harness uses its default.
|
|
49
54
|
- A panel URL as `<panel>`, a `PANELS_API` override, and a panel cache.
|
|
50
55
|
- A login: an owner cannot run an unpublished panel.
|
|
51
|
-
-
|
|
56
|
+
- Cost totals, and `cap` and `gap` fields.
|
package/bin/panels.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
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
7
|
import { buildReport, renderMarkdown } from '../lib/report.js';
|
|
8
|
-
import {
|
|
8
|
+
import { runTable } from '../lib/table.js';
|
|
9
9
|
|
|
10
10
|
// More parallel sessions on one account get 429 answers from the model api.
|
|
11
11
|
const PARALLEL_JUDGES = 4;
|
|
@@ -25,6 +25,17 @@ async function pool(items, size, fn) {
|
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const EXIT = { pass: 0, fail: 1, error: 2 };
|
|
29
|
+
|
|
30
|
+
/** Runs the judges in the score table, and exits from report.json when the engineer quits. */
|
|
31
|
+
async function runOnTerminal({ panel, runDir, harness, cwd, args }) {
|
|
32
|
+
await runTable({ panel, runDir, harness, cwd, target: args.target, brief: args.brief, sources: args.sources });
|
|
33
|
+
const reportPath = join(runDir, 'report.json');
|
|
34
|
+
if (!existsSync(reportPath)) return 2;
|
|
35
|
+
console.log(reportPath);
|
|
36
|
+
return EXIT[JSON.parse(readFileSync(reportPath, 'utf8')).status];
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
async function main() {
|
|
29
40
|
const args = parse(process.argv.slice(2));
|
|
30
41
|
if (args.help) {
|
|
@@ -51,10 +62,12 @@ async function main() {
|
|
|
51
62
|
}
|
|
52
63
|
const { panel } = fetched;
|
|
53
64
|
const cwd = process.cwd();
|
|
54
|
-
const judges =
|
|
55
|
-
const
|
|
65
|
+
const judges = panel.criteria.map((_, i) => [i]);
|
|
66
|
+
const time = new Date().toISOString().replaceAll(':', '-');
|
|
67
|
+
const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, time);
|
|
56
68
|
mkdirSync(runDir, { recursive: true });
|
|
57
69
|
writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
|
|
70
|
+
if (process.stdout.isTTY && process.stdin.isTTY) return runOnTerminal({ panel, runDir, harness, cwd, args });
|
|
58
71
|
const outcomes = await pool(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
|
|
59
72
|
dir: join(runDir, 'judges', String(j + 1)),
|
|
60
73
|
criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
|
|
@@ -71,7 +84,7 @@ async function main() {
|
|
|
71
84
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
72
85
|
console.log(markdown);
|
|
73
86
|
console.log(reportPath);
|
|
74
|
-
return
|
|
87
|
+
return EXIT[report.status];
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
// Node exits 1 on a crash, and 1 means "fail" to an agent loop, so a crash prints its stack and exits 2.
|
package/lib/args.js
CHANGED
|
@@ -5,7 +5,6 @@ export const SYNOPSIS = `panels run <panel> [target...] [flags]
|
|
|
5
5
|
<panel> owner/name, or owner/name/vN to pin version N
|
|
6
6
|
[target...] free words, joined with single spaces into one target
|
|
7
7
|
|
|
8
|
-
--size <n> number of judges default: the number of criteria
|
|
9
8
|
--harness <name> claude | codex default: claude if installed, else codex
|
|
10
9
|
--brief <file> the brief default: none
|
|
11
10
|
--sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
|
|
@@ -16,7 +15,6 @@ exit 1 fail: every judge answered, and one or more criteria scored below 4
|
|
|
16
15
|
exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed`;
|
|
17
16
|
|
|
18
17
|
const options = {
|
|
19
|
-
size: { type: 'string' },
|
|
20
18
|
harness: { type: 'string' },
|
|
21
19
|
brief: { type: 'string' },
|
|
22
20
|
sources: { type: 'string', multiple: true },
|
|
@@ -39,12 +37,10 @@ export function parse(argv) {
|
|
|
39
37
|
const match = PANEL.exec(panel);
|
|
40
38
|
if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name/vN` };
|
|
41
39
|
if (words.length === 0) return { error: 'no target: type what the panel reviews after the panel name, such as a URL, a path, or a branch' };
|
|
42
|
-
if (values.size !== undefined && !/^[1-9]\d*$/.test(values.size)) return { error: `--size needs a whole number of 1 or more, not ${values.size}` };
|
|
43
40
|
if (values.harness !== undefined && !['claude', 'codex'].includes(values.harness)) return { error: `--harness needs claude or codex, not ${values.harness}` };
|
|
44
41
|
return {
|
|
45
42
|
panel: { owner: match[1], name: match[2], version: match[3] && Number(match[3]) },
|
|
46
43
|
target: words.join(' '),
|
|
47
|
-
size: values.size && Number(values.size),
|
|
48
44
|
harness: values.harness,
|
|
49
45
|
brief: values.brief,
|
|
50
46
|
sources: values.sources ?? [],
|
package/lib/judge.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
const { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
|
|
6
6
|
const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
|
|
7
|
-
const MINUTES_PER_CRITERION = 20;
|
|
7
|
+
export const MINUTES_PER_CRITERION = 20;
|
|
8
8
|
const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
|
|
9
9
|
// With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
|
|
10
10
|
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:*)';
|
|
@@ -112,7 +112,7 @@ async function askCodex(j, prompt, thread) {
|
|
|
112
112
|
return { answer: parseAnswer(readFileSync(raw, 'utf8')), session: thread ?? JSON.parse(started).thread_id };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function parseAnswer(text) {
|
|
115
|
+
export function parseAnswer(text) {
|
|
116
116
|
try {
|
|
117
117
|
return JSON.parse(text);
|
|
118
118
|
} catch {
|
|
@@ -143,3 +143,29 @@ export async function runJudge({ dir, criteria, harness, cwd, target, brief, sou
|
|
|
143
143
|
}
|
|
144
144
|
return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
|
|
148
|
+
|
|
149
|
+
/** A TOML basic string, for the value of a codex -c flag. */
|
|
150
|
+
export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOML_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
|
|
154
|
+
* arguments that start the judge in its interactive harness.
|
|
155
|
+
*/
|
|
156
|
+
export function writeJudge({ dir, criteria, harness, cwd, target, brief, sources }) {
|
|
157
|
+
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}`;
|
|
160
|
+
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
161
|
+
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
|
|
162
|
+
if (harness === 'codex') {
|
|
163
|
+
// Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
|
|
164
|
+
// write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
|
|
165
|
+
return ['codex', '-C', dir, '-s', 'workspace-write', '--disable', 'multi_agent', '--disable', 'apps', '-c', `developer_instructions=${tomlString(`${SYSTEM_PROMPT}\n${closing}`)}`, prompt];
|
|
166
|
+
}
|
|
167
|
+
// Claude matches file paths only in Edit rules, which cover Write too; // starts an absolute path.
|
|
168
|
+
const allowed = `${ALLOWED_TOOLS},Edit(/${join(dir, 'answer.json')})`;
|
|
169
|
+
const add = addDirs(cwd, brief, sources).flatMap((d) => ['--add-dir', d]);
|
|
170
|
+
return ['claude', '--safe-mode', '--system-prompt', SYSTEM_PROMPT, '--tools', `${TOOLS},Write`, ...add, '--allowedTools', allowed, '--permission-mode', 'dontAsk', prompt];
|
|
171
|
+
}
|
package/lib/table.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The terminal mode of `panels run`: the score table, and a view of each judge. Each judge runs its interactive
|
|
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';
|
|
5
|
+
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
|
6
|
+
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
7
|
+
import xterm from '@xterm/headless';
|
|
8
|
+
import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
|
|
9
|
+
import { buildReport, renderMarkdown } from './report.js';
|
|
10
|
+
|
|
11
|
+
// More parallel sessions on one account get 429 answers from the model api.
|
|
12
|
+
const LIVE_JUDGES = 4;
|
|
13
|
+
const ORANGE = '\x1b[38;2;255;106;61m';
|
|
14
|
+
const GREY = '\x1b[90m';
|
|
15
|
+
const BOLD = '\x1b[1m';
|
|
16
|
+
const RESET = '\x1b[0m';
|
|
17
|
+
// ctrl-] returns from a judge to the table. A terminal in the kitty keyboard mode sends it as CSI 93;5u.
|
|
18
|
+
const BACK = ['\x1d', '\x1b[93;5u'];
|
|
19
|
+
// Turns off each mode that the screen of a judge can turn on, so the table gets the keys and the screen as before.
|
|
20
|
+
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';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The lines of the table. rows hold { rank, name, state, result }, and state is queued, running, done, or error.
|
|
24
|
+
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
25
|
+
*/
|
|
26
|
+
export function render({ title, rows, final, confirm, width }) {
|
|
27
|
+
const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
|
|
28
|
+
const pad = Math.max(...rows.map((r) => r.name.length)) + 2;
|
|
29
|
+
const lines = [` ${BOLD}panels run${RESET} ${title}`, rule];
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
|
|
32
|
+
if (r.state === 'running') lines.push(`${ORANGE}${head}…${RESET}`);
|
|
33
|
+
else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
|
|
34
|
+
else if (r.state === 'error') lines.push(`${head}error`);
|
|
35
|
+
else lines.push(`${head}${r.result.score} ${GREY}b${r.result.band}${RESET}`);
|
|
36
|
+
}
|
|
37
|
+
lines.push(rule);
|
|
38
|
+
if (final) {
|
|
39
|
+
const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
|
|
40
|
+
lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
|
|
41
|
+
}
|
|
42
|
+
lines.push(confirm ? ' judges still run. y quits, another key stays' : `${GREY} 1-${Math.min(rows.length, 9)} open judge q quit${RESET}`);
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The next { view, confirm } after one key, plus send: true when the key goes to the judge, or quit: true.
|
|
48
|
+
* view is 0 for the table, or N for judge N. open holds the ranks of the judges that have started.
|
|
49
|
+
*/
|
|
50
|
+
export function press({ view, confirm }, key, { open, running }) {
|
|
51
|
+
if (view) return BACK.includes(key) ? { view: 0, confirm: false } : { view, confirm, send: true };
|
|
52
|
+
if (confirm) return { view: 0, confirm: false, quit: key === 'y' };
|
|
53
|
+
if (key === 'q') return running ? { view: 0, confirm: true } : { view: 0, confirm: false, quit: true };
|
|
54
|
+
if (/^[1-9]$/.test(key) && open.includes(Number(key))) return { view: Number(key), confirm: false };
|
|
55
|
+
return { view: 0, confirm: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Runs the judges, shows the table, and writes the report. Resolves when the engineer quits. */
|
|
59
|
+
export function runTable({ panel, runDir, harness, cwd, target, brief, sources }) {
|
|
60
|
+
const out = process.stdout;
|
|
61
|
+
const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
|
|
62
|
+
const judges = [];
|
|
63
|
+
let rows = [];
|
|
64
|
+
let final = null;
|
|
65
|
+
let ui = { view: 0, confirm: false };
|
|
66
|
+
let footerTimer;
|
|
67
|
+
|
|
68
|
+
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
69
|
+
// A judge gets one line less than the terminal. The last line holds the footer.
|
|
70
|
+
const size = () => ({ cols: out.columns, rows: out.rows - 1 });
|
|
71
|
+
|
|
72
|
+
function state(c, i) {
|
|
73
|
+
const file = join(judgeDir(c.rank), 'answer.json');
|
|
74
|
+
if (existsSync(file)) {
|
|
75
|
+
const answer = parseAnswer(readFileSync(file, 'utf8'));
|
|
76
|
+
const errors = check(answer, [c]);
|
|
77
|
+
return errors.length ? { state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { state: 'done', result: answer.results[0] };
|
|
78
|
+
}
|
|
79
|
+
if (judges[i] === undefined) return { state: 'queued' };
|
|
80
|
+
if (Date.now() - judges[i].started > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
|
|
81
|
+
return { state: 'running' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function start(rank) {
|
|
85
|
+
const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criteria: [criteria[rank - 1]], harness, cwd, target, brief, sources });
|
|
86
|
+
const { cols, rows: lines } = size();
|
|
87
|
+
const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
|
|
88
|
+
const serializer = new SerializeAddon();
|
|
89
|
+
term.loadAddon(serializer);
|
|
90
|
+
const proc = pty.spawn(command, argv, { name: 'xterm-256color', cols, rows: lines, cwd, env: process.env });
|
|
91
|
+
const j = { started: Date.now(), term, serializer, proc, modes: { 25: 'h', 1006: 'l' }, exited: false };
|
|
92
|
+
// The write callback keeps the order of the live output and of the screen that showJudge writes.
|
|
93
|
+
proc.onData((data) => term.write(data, () => {
|
|
94
|
+
if (ui.view !== rank) return;
|
|
95
|
+
out.write(data);
|
|
96
|
+
footer();
|
|
97
|
+
}));
|
|
98
|
+
proc.onExit(() => { j.exited = true; });
|
|
99
|
+
// The headless terminal answers the queries of the harness, such as the cursor position.
|
|
100
|
+
term.onData((data) => proc.write(data));
|
|
101
|
+
// The serializer does not keep the cursor visibility (25) or the mouse encoding (1006), so each judge keeps them here.
|
|
102
|
+
for (const final of ['h', 'l']) {
|
|
103
|
+
term.parser.registerCsiHandler({ prefix: '?', final }, (params) => {
|
|
104
|
+
for (const p of params) if (p in j.modes) j.modes[p] = final;
|
|
105
|
+
return false;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
judges[rank - 1] = j;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function drawFooter() {
|
|
112
|
+
if (!ui.view) return;
|
|
113
|
+
const { cursorX, cursorY } = judges[ui.view - 1].term.buffer.active;
|
|
114
|
+
out.write(`\x1b[${out.rows};1H${RESET}${GREY} judge ${ui.view} ctrl-] scores${RESET}\x1b[K\x1b[${cursorY + 1};${cursorX + 1}H`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Waits for a pause in the output, so the footer does not land inside an escape sequence that a chunk split.
|
|
118
|
+
function footer() {
|
|
119
|
+
clearTimeout(footerTimer);
|
|
120
|
+
footerTimer = setTimeout(drawFooter, 50);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function showJudge(rank) {
|
|
124
|
+
const j = judges[rank - 1];
|
|
125
|
+
j.term.write('', () => {
|
|
126
|
+
if (ui.view !== rank) return;
|
|
127
|
+
// The table already holds the alternate screen, so a judge on its alternate screen gets a clear after
|
|
128
|
+
// the normal screen, which the serializer writes first. The scroll region keeps the judge off the footer line.
|
|
129
|
+
const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
|
|
130
|
+
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows - 1}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
|
|
131
|
+
drawFooter();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function finish() {
|
|
136
|
+
const outcomes = rows.map((r) => (r.state === 'done' ? { results: [r.result] } : { error: r.error }));
|
|
137
|
+
const report = buildReport({ panel, target, cwd, harness, judges: rows.map((_, i) => [i]), outcomes });
|
|
138
|
+
const reportPath = join(runDir, 'report.json');
|
|
139
|
+
writeFileSync(join(runDir, 'report.md'), renderMarkdown(report));
|
|
140
|
+
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
141
|
+
final = { status: report.status, path: relative(cwd, reportPath) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function draw() {
|
|
145
|
+
const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm: ui.confirm, width: Math.min(out.columns, 100) });
|
|
146
|
+
out.write(`\x1b[H${lines.map((l) => `${l}\x1b[K`).join('\n')}\x1b[J`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tick() {
|
|
150
|
+
if (!final) {
|
|
151
|
+
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
152
|
+
const live = rows.filter((r) => r.state === 'running').length;
|
|
153
|
+
for (const r of rows.filter((x) => x.state === 'queued').slice(0, LIVE_JUDGES - live)) {
|
|
154
|
+
start(r.rank);
|
|
155
|
+
r.state = 'running';
|
|
156
|
+
}
|
|
157
|
+
if (rows.every((r) => r.state === 'done' || r.state === 'error')) finish();
|
|
158
|
+
}
|
|
159
|
+
if (!ui.view) draw();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return new Promise((done) => {
|
|
163
|
+
const timer = setInterval(tick, 1000);
|
|
164
|
+
out.write('\x1b[?1049h\x1b[?25l');
|
|
165
|
+
process.stdin.setRawMode(true);
|
|
166
|
+
process.stdin.on('data', (data) => {
|
|
167
|
+
const key = data.toString();
|
|
168
|
+
const from = ui.view;
|
|
169
|
+
const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running') });
|
|
170
|
+
ui = { view: next.view, confirm: next.confirm };
|
|
171
|
+
if (next.send) {
|
|
172
|
+
judges[from - 1].proc.write(key);
|
|
173
|
+
} else if (next.quit) {
|
|
174
|
+
clearInterval(timer);
|
|
175
|
+
// The harness on PATH can be a shell script that runs the real binary as a child and waits for it, so
|
|
176
|
+
// SIGHUP goes to the process group of the judge. The pty makes each judge the leader of its own group.
|
|
177
|
+
for (const j of judges) if (j && !j.exited) process.kill(-j.proc.pid, 'SIGHUP');
|
|
178
|
+
out.write(`${TABLE_MODES}\x1b[?25h\x1b[?1049l`);
|
|
179
|
+
process.stdin.setRawMode(false);
|
|
180
|
+
process.stdin.pause();
|
|
181
|
+
done();
|
|
182
|
+
} else if (ui.view) {
|
|
183
|
+
showJudge(ui.view);
|
|
184
|
+
} else {
|
|
185
|
+
if (from) out.write(TABLE_MODES);
|
|
186
|
+
draw();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
out.on('resize', () => {
|
|
190
|
+
const { cols, rows: lines } = size();
|
|
191
|
+
for (const j of judges) {
|
|
192
|
+
if (j && !j.exited) j.proc.resize(cols, lines);
|
|
193
|
+
j?.term.resize(cols, lines);
|
|
194
|
+
}
|
|
195
|
+
if (ui.view) showJudge(ui.view);
|
|
196
|
+
else draw();
|
|
197
|
+
});
|
|
198
|
+
tick();
|
|
199
|
+
});
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xsft/panels",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Run a panel of AI judges from cross.software/panels against a target.",
|
|
6
6
|
"type": "module",
|
|
@@ -8,5 +8,10 @@
|
|
|
8
8
|
"files": ["bin", "lib", "prompts", "schema", "README.md"],
|
|
9
9
|
"license": "UNLICENSED",
|
|
10
10
|
"engines": { "node": ">=20" },
|
|
11
|
-
"scripts": { "test": "node --test test/*.test.js" }
|
|
11
|
+
"scripts": { "test": "node --test test/*.test.js" },
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@homebridge/node-pty-prebuilt-multiarch": "0.14.1",
|
|
14
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
15
|
+
"@xterm/headless": "6.0.0"
|
|
16
|
+
}
|
|
12
17
|
}
|
package/lib/split.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/** Returns, for each judge, the indexes of the criteria it scores. n is the number of criteria. */
|
|
2
|
-
export function split(n, size) {
|
|
3
|
-
if (size <= n) {
|
|
4
|
-
const judges = [];
|
|
5
|
-
let start = 0;
|
|
6
|
-
for (let j = 0; j < size; j++) {
|
|
7
|
-
const length = Math.floor(n / size) + (j < n % size ? 1 : 0);
|
|
8
|
-
judges.push(Array.from({ length }, (_, x) => start + x));
|
|
9
|
-
start += length;
|
|
10
|
-
}
|
|
11
|
-
return judges;
|
|
12
|
-
}
|
|
13
|
-
return Array.from({ length: n }, (_, i) => Array(Math.floor(size / n) + (i < size % n ? 1 : 0)).fill([i])).flat();
|
|
14
|
-
}
|