@xsft/panels 0.0.0-stage → 0.2.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
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # @xsft/panels
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ ```text
6
+ panels run <panel> [target...] [flags]
7
+
8
+ <panel> owner/name, or owner/name/vN to pin version N
9
+ [target...] free words, joined with single spaces into one target
10
+
11
+ --harness <name> claude | codex default: claude if installed, else codex
12
+ --brief <file> the brief default: none
13
+ --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
14
+ -- end of the flags; each later word joins the target
15
+
16
+ exit 0 pass: every criterion scored 4
17
+ exit 1 fail: every judge answered, and one or more criteria scored below 4
18
+ exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed
19
+ ```
20
+
21
+ CAUTION: A judge with web fetch can send what it reads to a URL. Do not run a panel on a repository that you do not trust.
22
+
23
+ ```sh
24
+ npx @xsft/panels run xsft/marketing-site https://example.com --brief docs/brief.md --harness claude
25
+ npx @xsft/panels run xsft/marketing-site/v6 . at http://localhost:4321 --sources docs --harness codex
26
+ ```
27
+
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
+ ## tmux mode
31
+
32
+ On a terminal with `tmux` on PATH, `panels run` opens a tmux session: window 0 shows the scores, and window N runs judge N in the interactive harness. Each judge writes `judges/N/answer.json` and stays open; window 0 writes the report when each judge has an answer or an error, and the command exits from `report.json` when you leave the session. Without a terminal or tmux, the judges run in the background (`claude -p`, `codex exec`).
33
+
34
+ Keys in window 0: `1` to `9` open the window of that judge (tmux `prefix 0` comes back), `q` ends the session and asks `y` while a judge still runs.
35
+
36
+ ## Run folder
37
+
38
+ Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
39
+
40
+ - `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`.
42
+ - `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k. In tmux mode, `start.sh` and `answer.json` take the place of the raw answer.
43
+ - `run.json`, in tmux mode: the session name, the harness, the folder, and the target, for window 0.
44
+
45
+ The last line on stdout is the path of `report.json`.
46
+
47
+ ## Skipped
48
+
49
+ - Run upload to cross.software, and `--no-upload`.
50
+ - `--version`: `owner/name/vN` pins a version.
51
+ - `--out`: the run folder is fixed.
52
+ - `--json`: stdout ends with the path of `report.json`.
53
+ - `--concurrency`: fixed at 4.
54
+ - `--model`: each harness uses its default.
55
+ - A panel URL as `<panel>`, a `PANELS_API` override, and a panel cache.
56
+ - A login: an owner cannot run an unpublished panel.
57
+ - Cost totals, and `cap` and `gap` fields.
package/bin/panels.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { delimiter, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parse, SYNOPSIS } from '../lib/args.js';
7
+ import { runJudge, writeJudge } from '../lib/judge.js';
8
+ import { fetchPanel } from '../lib/panel.js';
9
+ import { buildReport, renderMarkdown } from '../lib/report.js';
10
+
11
+ // More parallel sessions on one account get 429 answers from the model api.
12
+ const PARALLEL_JUDGES = 4;
13
+
14
+ const onPath = (name) => process.env.PATH.split(delimiter).some((dir) => existsSync(join(dir, name)));
15
+
16
+ async function pool(items, size, fn) {
17
+ const out = [];
18
+ let next = 0;
19
+ const worker = async () => {
20
+ while (next < items.length) {
21
+ const i = next++;
22
+ out[i] = await fn(items[i], i);
23
+ }
24
+ };
25
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
26
+ return out;
27
+ }
28
+
29
+ const EXIT = { pass: 0, fail: 1, error: 2 };
30
+
31
+ /** Starts the scores window in a new tmux session, attaches to it, and exits from report.json when the attach returns. */
32
+ function runInTmux({ panel, runDir, time, harness, cwd, args }) {
33
+ const session = `panels-${time.slice(11, 16)}`;
34
+ writeFileSync(join(runDir, 'run.json'), JSON.stringify({ session, harness, cwd, target: args.target }, null, 2));
35
+ panel.criteria.forEach((c, i) => writeJudge({ dir: join(runDir, 'judges', String(i + 1)), criteria: [{ rank: i + 1, ...c }], harness, cwd, target: args.target, brief: args.brief, sources: args.sources }));
36
+ execFileSync('tmux', ['new-session', '-d', '-s', session, '-n', 'scores', process.execPath, fileURLToPath(new URL('../lib/table.js', import.meta.url)), runDir]);
37
+ if (process.env.TMUX) {
38
+ execFileSync('tmux', ['switch-client', '-t', session]);
39
+ return 0;
40
+ }
41
+ spawnSync('tmux', ['attach-session', '-t', session], { stdio: 'inherit' });
42
+ const reportPath = join(runDir, 'report.json');
43
+ if (!existsSync(reportPath)) return 2;
44
+ console.log(reportPath);
45
+ return EXIT[JSON.parse(readFileSync(reportPath, 'utf8')).status];
46
+ }
47
+
48
+ async function main() {
49
+ const args = parse(process.argv.slice(2));
50
+ if (args.help) {
51
+ console.log(SYNOPSIS);
52
+ return 0;
53
+ }
54
+ if (args.usage) {
55
+ console.error(SYNOPSIS);
56
+ return 2;
57
+ }
58
+ if (args.error) {
59
+ console.error(`panels: ${args.error}`);
60
+ return 2;
61
+ }
62
+ const harness = args.harness ?? (onPath('claude') ? 'claude' : 'codex');
63
+ if (!onPath(harness)) {
64
+ console.error(`panels: harness missing: ${harness} is not on PATH`);
65
+ return 2;
66
+ }
67
+ const fetched = await fetchPanel(args.panel);
68
+ if (fetched.error) {
69
+ console.error(`panels: ${fetched.error}`);
70
+ return 2;
71
+ }
72
+ const { panel } = fetched;
73
+ const cwd = process.cwd();
74
+ const judges = panel.criteria.map((_, i) => [i]);
75
+ const time = new Date().toISOString().replaceAll(':', '-');
76
+ const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, time);
77
+ mkdirSync(runDir, { recursive: true });
78
+ writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
79
+ if (process.stdout.isTTY && process.stdin.isTTY && onPath('tmux')) return runInTmux({ panel, runDir, time, harness, cwd, args });
80
+ const outcomes = await pool(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
81
+ dir: join(runDir, 'judges', String(j + 1)),
82
+ criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
83
+ harness,
84
+ cwd,
85
+ target: args.target,
86
+ brief: args.brief,
87
+ sources: args.sources,
88
+ }));
89
+ const report = buildReport({ panel, target: args.target, cwd, harness, judges, outcomes });
90
+ const markdown = renderMarkdown(report);
91
+ const reportPath = join(runDir, 'report.json');
92
+ writeFileSync(join(runDir, 'report.md'), markdown);
93
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
94
+ console.log(markdown);
95
+ console.log(reportPath);
96
+ return EXIT[report.status];
97
+ }
98
+
99
+ // Node exits 1 on a crash, and 1 means "fail" to an agent loop, so a crash prints its stack and exits 2.
100
+ main().then((code) => { process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 2; });
package/lib/args.js ADDED
@@ -0,0 +1,48 @@
1
+ import { parseArgs } from 'node:util';
2
+
3
+ export const SYNOPSIS = `panels run <panel> [target...] [flags]
4
+
5
+ <panel> owner/name, or owner/name/vN to pin version N
6
+ [target...] free words, joined with single spaces into one target
7
+
8
+ --harness <name> claude | codex default: claude if installed, else codex
9
+ --brief <file> the brief default: none
10
+ --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
11
+ -- end of the flags; each later word joins the target
12
+
13
+ exit 0 pass: every criterion scored 4
14
+ exit 1 fail: every judge answered, and one or more criteria scored below 4
15
+ exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed`;
16
+
17
+ const options = {
18
+ harness: { type: 'string' },
19
+ brief: { type: 'string' },
20
+ sources: { type: 'string', multiple: true },
21
+ help: { type: 'boolean' },
22
+ };
23
+
24
+ const PANEL = /^([a-z0-9-]+)\/([a-z0-9-]+)(?:\/v([1-9]\d*))?$/;
25
+
26
+ /** Returns the run options, { help: true }, { usage: true }, or { error } with a one-line message. */
27
+ export function parse(argv) {
28
+ // strict is off so that an unknown flag comes back as a token and gets the one-line message below.
29
+ const { values, positionals, tokens } = parseArgs({ args: argv, options, strict: false, allowPositionals: true, tokens: true });
30
+ for (const token of tokens.filter((t) => t.kind === 'option')) {
31
+ if (!Object.hasOwn(options, token.name)) return { error: `unknown flag ${token.rawName}` };
32
+ if (token.name !== 'help' && token.value === undefined) return { error: `${token.rawName} needs a value` };
33
+ }
34
+ if (values.help) return { help: true };
35
+ const [command, panel, ...words] = positionals;
36
+ if (command !== 'run' || panel === undefined) return { usage: true };
37
+ const match = PANEL.exec(panel);
38
+ if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name/vN` };
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' };
40
+ if (values.harness !== undefined && !['claude', 'codex'].includes(values.harness)) return { error: `--harness needs claude or codex, not ${values.harness}` };
41
+ return {
42
+ panel: { owner: match[1], name: match[2], version: match[3] && Number(match[3]) },
43
+ target: words.join(' '),
44
+ harness: values.harness,
45
+ brief: values.brief,
46
+ sources: values.sources ?? [],
47
+ };
48
+ }
package/lib/judge.js ADDED
@@ -0,0 +1,176 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
7
+ const SYSTEM_PROMPT_FILE = fileURLToPath(new URL('../prompts/judge-system-prompt.txt', import.meta.url));
8
+ const SYSTEM_PROMPT = readFileSync(SYSTEM_PROMPT_FILE, 'utf8');
9
+ export const MINUTES_PER_CRITERION = 20;
10
+ const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
11
+ // With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
12
+ 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:*)';
13
+
14
+ export const bandToScore = (band) => (band <= 3 ? 1 : band <= 6 ? 2 : band <= 8 ? 3 : 4);
15
+
16
+ /**
17
+ * The user message of a judge. criteria items are { rank, name, description, body }. Each part sits
18
+ * in a tag, because a body is markdown with headings of its own.
19
+ */
20
+ export function userMessage(criteria, { target, brief, sources }) {
21
+ const parts = criteria.map((c) => `<criterion rank="${c.rank}" name="${c.name}">\nDescription: ${c.description}\n\n${c.body}\n</criterion>`);
22
+ parts.push(`<target>\n${target}\n</target>`, `<brief>\n${brief ?? 'none supplied'}\n</brief>`, `<sources>\n${sources.length ? sources.join('\n') : 'none'}\n</sources>`);
23
+ return `${parts.join('\n\n')}\n`;
24
+ }
25
+
26
+ /** The output schema of a judge: { results }, one judgeResult for each of its criteria, with rank and name fixed. */
27
+ export function judgeSchema(criteria) {
28
+ const items = criteria.map((c) => ({
29
+ ...judgeResult,
30
+ properties: { ...judgeResult.properties, rank: { type: 'integer', const: c.rank }, criterion: { type: 'string', const: c.name } },
31
+ }));
32
+ return {
33
+ type: 'object',
34
+ additionalProperties: false,
35
+ required: ['results'],
36
+ properties: { results: { type: 'array', minItems: items.length, maxItems: items.length, items: items.length === 1 ? items[0] : { anyOf: items } } },
37
+ };
38
+ }
39
+
40
+ /** Returns the reasons the answer of a judge is not valid, or an empty array. */
41
+ export function check(answer, criteria) {
42
+ if (!Array.isArray(answer?.results)) return ['The answer must be a JSON object with a results array.'];
43
+ if (answer.results.length !== criteria.length) return [`results must hold ${criteria.length} items, one for each criterion, in rank order.`];
44
+ return criteria.flatMap((c, i) => {
45
+ const r = answer.results[i];
46
+ const missing = judgeResult.required.filter((key) => !(key in Object(r)));
47
+ if (missing.length) return [`Item ${i + 1} lacks ${missing.join(', ')}.`];
48
+ const errors = [];
49
+ if (r.rank !== c.rank || r.criterion !== c.name) errors.push(`Item ${i + 1} must have rank ${c.rank} and criterion "${c.name}".`);
50
+ 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.`);
51
+ else if (r.score !== bandToScore(r.band)) errors.push(`Rank ${c.rank}: band ${r.band} maps to score ${bandToScore(r.band)}, not ${r.score}.`);
52
+ 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.`);
53
+ return errors;
54
+ });
55
+ }
56
+
57
+ /** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
58
+ export function addDirs(cwd, brief, sources) {
59
+ const paths = [brief && dirname(resolve(cwd, brief)), ...sources.filter((s) => !/^https?:\/\//.test(s)).map((s) => resolve(cwd, s))];
60
+ return [...new Set(paths.filter(Boolean))].filter((p) => relative(cwd, p).split(sep)[0] === '..');
61
+ }
62
+
63
+ function exec(command, args, { cwd, stdin, timeout }) {
64
+ return new Promise((done) => {
65
+ const child = spawn(command, args, { cwd, timeout, stdio: [stdin, 'pipe', 'pipe'] });
66
+ let out = '';
67
+ let err = '';
68
+ child.stdout.on('data', (d) => { out += d; });
69
+ child.stderr.on('data', (d) => { err += d; });
70
+ child.on('close', (code, signal) => {
71
+ if (signal) done({ error: `${command} stopped at the time limit of ${timeout / 60_000} minutes` });
72
+ else if (code) done({ error: `${command} exited ${code}: ${(err || out).trim().slice(-1000)}` });
73
+ else done({ out });
74
+ });
75
+ });
76
+ }
77
+
78
+ async function askClaude(j, prompt, session) {
79
+ const args = [
80
+ '-p', '--safe-mode',
81
+ '--tools', TOOLS,
82
+ '--allowedTools', ALLOWED_TOOLS,
83
+ '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
84
+ ...j.addDirs.flatMap((d) => ['--add-dir', d]),
85
+ '--system-prompt', SYSTEM_PROMPT,
86
+ '--json-schema', JSON.stringify(j.schema),
87
+ '--output-format', 'json',
88
+ ...(session ? ['--resume', session] : []),
89
+ prompt,
90
+ ];
91
+ const run = await exec(j.command, args, { cwd: j.cwd, stdin: 'ignore', timeout: j.timeout });
92
+ if (run.error) return run;
93
+ writeFileSync(join(j.dir, 'raw.json'), run.out);
94
+ // With verbose on in the user settings, the output is an array of messages and not one result object.
95
+ const result = [JSON.parse(run.out)].flat().find((m) => m.type === 'result');
96
+ if (result.is_error) return { error: `claude answered an error: ${result.result}` };
97
+ return { answer: result.structured_output, session: result.session_id };
98
+ }
99
+
100
+ // --disable apps: the apps feature gives Codex the connectors of the ChatGPT account, and some of them write
101
+ // (chatgpt_space_create_page, chatgpt_space_trash_page). --ignore-user-config does not turn them off.
102
+ async function askCodex(j, prompt, thread) {
103
+ const raw = join(j.dir, 'raw.json');
104
+ const instructions = `developer_instructions=${JSON.stringify(SYSTEM_PROMPT)}`;
105
+ const output = ['--output-schema', join(j.dir, 'schema.json'), '-o', raw, '--json'];
106
+ const args = thread
107
+ ? ['exec', 'resume', thread, prompt, '--skip-git-repo-check', '--ignore-user-config', '-c', 'sandbox_mode="read-only"', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output]
108
+ : ['exec', '-', '-C', j.cwd, '--skip-git-repo-check', '--ignore-user-config', '-s', 'read-only', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output];
109
+ const stdin = thread ? 'ignore' : openSync(join(j.dir, 'prompt.md'));
110
+ const run = await exec(j.command, args, { cwd: j.cwd, stdin, timeout: j.timeout });
111
+ if (!thread) closeSync(stdin);
112
+ if (run.error) return run;
113
+ const started = run.out.split('\n').find((line) => line.includes('"thread.started"'));
114
+ return { answer: parseAnswer(readFileSync(raw, 'utf8')), session: thread ?? JSON.parse(started).thread_id };
115
+ }
116
+
117
+ export function parseAnswer(text) {
118
+ try {
119
+ return JSON.parse(text);
120
+ } catch {
121
+ // check() rejects undefined, so text that is not JSON gets the one re-ask.
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
128
+ * Returns { results } or { error }. command is the harness binary; the tests pass a stub.
129
+ */
130
+ export async function runJudge({ dir, criteria, harness, cwd, target, brief, sources }, command = harness) {
131
+ mkdirSync(dir, { recursive: true });
132
+ const prompt = userMessage(criteria, { target, brief, sources });
133
+ const schema = judgeSchema(criteria);
134
+ writeFileSync(join(dir, 'prompt.md'), prompt);
135
+ writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
136
+ const j = { dir, cwd, schema, command, addDirs: addDirs(cwd, brief, sources), timeout: criteria.length * MINUTES_PER_CRITERION * 60_000 };
137
+ const ask = harness === 'claude' ? askClaude : askCodex;
138
+ let reply = await ask(j, prompt);
139
+ if (reply.error) return reply;
140
+ let errors = check(reply.answer, criteria);
141
+ if (errors.length) {
142
+ 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);
143
+ if (reply.error) return reply;
144
+ errors = check(reply.answer, criteria);
145
+ }
146
+ return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
147
+ }
148
+
149
+ /** Quotes a string for sh. */
150
+ export const sq = (s) => `'${s.replaceAll("'", "'\\''")}'`;
151
+
152
+ const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
153
+
154
+ /** A TOML basic string, for the value of a codex -c flag. */
155
+ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOML_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
156
+
157
+ /** The shell command that starts one judge of the tmux mode in its interactive harness. */
158
+ export function startScript({ dir, harness, cwd, brief, sources }) {
159
+ const prompt = `"$(cat ${sq(join(dir, 'prompt.md'))})"`;
160
+ if (harness === 'codex') {
161
+ return `codex -C ${sq(dir)} -s workspace-write --disable multi_agent --disable apps -c ${sq(`developer_instructions=${tomlString(SYSTEM_PROMPT)}`)} ${prompt}\n`;
162
+ }
163
+ const add = addDirs(cwd, brief, sources).map((d) => ` --add-dir ${sq(d)}`).join('');
164
+ // Claude matches file paths only in Edit rules, which cover Write too; // starts an absolute path.
165
+ const allowed = `${ALLOWED_TOOLS},Edit(/${join(dir, 'answer.json')})`;
166
+ return `claude --safe-mode --system-prompt "$(cat ${sq(SYSTEM_PROMPT_FILE)})" --tools ${TOOLS},Write${add} --allowedTools ${sq(allowed)} --permission-mode dontAsk ${prompt}\n`;
167
+ }
168
+
169
+ /** Writes prompt.md, schema.json, and start.sh of one judge of the tmux mode. */
170
+ export function writeJudge({ dir, criteria, harness, cwd, target, brief, sources }) {
171
+ mkdirSync(dir, { recursive: true });
172
+ 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')}. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
173
+ writeFileSync(join(dir, 'prompt.md'), `${userMessage(criteria, { target, brief, sources })}\n${closing}`);
174
+ writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
175
+ writeFileSync(join(dir, 'start.sh'), startScript({ dir, harness, cwd, brief, sources }));
176
+ }
package/lib/panel.js ADDED
@@ -0,0 +1,9 @@
1
+ const API = 'https://cross.software/panels/api/panels';
2
+
3
+ /** Reads a panel from the public api. An unpublished panel also answers 404. */
4
+ export async function fetchPanel({ owner, name, version }) {
5
+ const res = await fetch(`${API}/${owner}/${name}${version ? `/v/${version}` : ''}`);
6
+ if (res.status === 404) return { error: 'panel not found' };
7
+ if (!res.ok) return { error: `panel fetch failed: ${res.status}` };
8
+ return { panel: await res.json() };
9
+ }
package/lib/report.js ADDED
@@ -0,0 +1,43 @@
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.
5
+ */
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 })));
10
+ 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 };
13
+ });
14
+ 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 };
16
+ }
17
+
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`;
43
+ }
package/lib/table.js ADDED
@@ -0,0 +1,119 @@
1
+ // Window 0 of the tmux mode: `node table.js <run folder>`. It starts the judge windows, shows the scores, and writes the report.
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { check, MINUTES_PER_CRITERION, parseAnswer } from './judge.js';
7
+ import { buildReport, renderMarkdown } from './report.js';
8
+
9
+ // More parallel sessions on one account get 429 answers from the model api.
10
+ const LIVE_JUDGES = 4;
11
+ const ORANGE = '\x1b[38;2;255;106;61m';
12
+ const GREY = '\x1b[90m';
13
+ const BOLD = '\x1b[1m';
14
+ const RESET = '\x1b[0m';
15
+
16
+ /**
17
+ * The lines of window 0. rows hold { rank, name, state, result }, and state is queued, running, done, or error.
18
+ * final is null until the report exists, then { status, path }. confirm is true while q waits for y.
19
+ */
20
+ export function render({ title, rows, final, confirm, width }) {
21
+ const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
22
+ const pad = Math.max(...rows.map((r) => r.name.length)) + 2;
23
+ const lines = [` ${BOLD}panels run${RESET} ${title}`, rule];
24
+ for (const r of rows) {
25
+ const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
26
+ if (r.state === 'running') lines.push(`${ORANGE}${head}…${RESET}`);
27
+ else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
28
+ else if (r.state === 'error') lines.push(`${head}error`);
29
+ else lines.push(`${head}${r.result.score} ${GREY}b${r.result.band}${RESET}`);
30
+ }
31
+ lines.push(rule);
32
+ if (final) {
33
+ const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
34
+ lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
35
+ }
36
+ lines.push(confirm ? ' judges still run. y quits, another key stays' : `${GREY} 1-${Math.min(rows.length, 9)} open judge q quit${RESET}`);
37
+ return lines;
38
+ }
39
+
40
+ function main(runDir) {
41
+ const read = (file) => JSON.parse(readFileSync(join(runDir, file), 'utf8'));
42
+ const panel = read('panel.json');
43
+ const { session, harness, cwd, target } = read('run.json');
44
+ const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
45
+ const started = [];
46
+ let rows = [];
47
+ let final = null;
48
+ let confirm = false;
49
+
50
+ const judgeDir = (rank) => join(runDir, 'judges', String(rank));
51
+
52
+ function state(c, i) {
53
+ const file = join(judgeDir(c.rank), 'answer.json');
54
+ if (existsSync(file)) {
55
+ const answer = parseAnswer(readFileSync(file, 'utf8'));
56
+ const errors = check(answer, [c]);
57
+ return errors.length ? { state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { state: 'done', result: answer.results[0] };
58
+ }
59
+ if (started[i] === undefined) return { state: 'queued' };
60
+ if (Date.now() - started[i] > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
61
+ return { state: 'running' };
62
+ }
63
+
64
+ function start(rank) {
65
+ started[rank - 1] = Date.now();
66
+ execFileSync('tmux', ['new-window', '-d', '-t', `${session}:${rank}`, '-n', `judge-${rank}`, '-c', cwd, 'sh', join(judgeDir(rank), 'start.sh')]);
67
+ }
68
+
69
+ function finish() {
70
+ const outcomes = rows.map((r) => (r.state === 'done' ? { results: [r.result] } : { error: r.error }));
71
+ const report = buildReport({ panel, target, cwd, harness, judges: rows.map((_, i) => [i]), outcomes });
72
+ const reportPath = join(runDir, 'report.json');
73
+ writeFileSync(join(runDir, 'report.md'), renderMarkdown(report));
74
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
75
+ final = { status: report.status, path: relative(cwd, reportPath) };
76
+ }
77
+
78
+ function draw() {
79
+ const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm, width: Math.min(process.stdout.columns, 100) });
80
+ process.stdout.write(`\x1b[H${lines.map((l) => `${l}\x1b[K`).join('\n')}\x1b[J`);
81
+ }
82
+
83
+ function tick() {
84
+ if (!final) {
85
+ rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
86
+ const live = rows.filter((r) => r.state === 'running').length;
87
+ for (const r of rows.filter((x) => x.state === 'queued').slice(0, LIVE_JUDGES - live)) {
88
+ start(r.rank);
89
+ r.state = 'running';
90
+ }
91
+ if (rows.every((r) => r.state === 'done' || r.state === 'error')) finish();
92
+ }
93
+ draw();
94
+ }
95
+
96
+ const quit = () => execFileSync('tmux', ['kill-session', '-t', session]);
97
+
98
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
99
+ process.stdin.setRawMode(true);
100
+ process.stdin.on('data', (data) => {
101
+ const key = data.toString();
102
+ if (confirm) {
103
+ confirm = false;
104
+ if (key === 'y') quit();
105
+ } else if (key === 'q') {
106
+ if (rows.some((r) => r.state === 'running')) confirm = true;
107
+ else quit();
108
+ } else if (/^[1-9]$/.test(key) && Number(key) <= rows.length) {
109
+ // A queued judge has no window yet, so tmux fails, and its message goes to the ignored pipe.
110
+ spawnSync('tmux', ['select-window', '-t', `${session}:${key}`]);
111
+ }
112
+ draw();
113
+ });
114
+ process.stdout.on('resize', draw);
115
+ tick();
116
+ setInterval(tick, 1000);
117
+ }
118
+
119
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv[2]);
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
+ "type": "module",
7
+ "bin": { "panels": "bin/panels.js" },
8
+ "files": ["bin", "lib", "prompts", "schema", "README.md"],
9
+ "license": "UNLICENSED",
10
+ "engines": { "node": ">=20" },
11
+ "scripts": { "test": "node --test test/*.test.js" }
12
+ }
@@ -0,0 +1,91 @@
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.
2
+
3
+ YOUR INPUTS
4
+
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.
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
+ - The brief: a file path, or "none supplied".
9
+ - The sources: paths or URLs, or "none".
10
+
11
+ Your working directory is the folder where the engineer ran the command.
12
+
13
+ THE TARGET
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.
16
+
17
+ ORDER
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.
20
+
21
+ HOW TO SCORE
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.
32
+
33
+ EVIDENCE
34
+
35
+ - Each claim needs evidence that the engineer can check: a URL, a file and line, a quote, or a command and its output.
36
+ - 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.
39
+
40
+ RECORDS AND FIXES
41
+
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.
44
+
45
+ PROHIBITIONS
46
+
47
+ - Do not change the body, the bands, the caps, or the map.
48
+ - 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.
50
+ - Do not submit forms, send messages, or post data.
51
+ - Do not read the .panels folder. It holds earlier reports.
52
+
53
+ OUTPUT
54
+
55
+ Return one JSON object and nothing else. Give one item in results for each of your criteria, in rank order.
56
+
57
+ {
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
+ }]
69
+ }
70
+
71
+ Example of one item:
72
+
73
+ {
74
+ "rank": 5,
75
+ "criterion": "Original design assets",
76
+ "band": 5,
77
+ "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
+ }]
91
+ }
@@ -0,0 +1,125 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "required": ["status", "panel", "target", "cwd", "harness", "size", "criteria", "errors"],
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
+ },
18
+ "target": { "type": "string" },
19
+ "cwd": { "type": "string" },
20
+ "harness": { "enum": ["claude", "codex"] },
21
+ "size": { "type": "integer", "minimum": 1 },
22
+ "criteria": {
23
+ "type": "array",
24
+ "items": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": ["rank", "name", "score", "judges"],
28
+ "properties": {
29
+ "rank": { "type": "integer" },
30
+ "name": { "type": "string" },
31
+ "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" }
56
+ }
57
+ }
58
+ }
59
+ },
60
+ "$defs": {
61
+ "judgeResult": {
62
+ "type": "object",
63
+ "additionalProperties": false,
64
+ "required": ["rank", "criterion", "band", "score", "verdict", "records", "evidence", "notChecked", "fixes"],
65
+ "properties": {
66
+ "rank": { "type": "integer" },
67
+ "criterion": { "type": "string" },
68
+ "band": { "type": "integer", "minimum": 1, "maximum": 10 },
69
+ "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
+ }
122
+ }
123
+ }
124
+ }
125
+ }