@xsft/panels 0.0.0-stage → 0.1.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,51 @@
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 or more criteria 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
+ --size <n> number of judges default: the number of criteria
12
+ --harness <name> claude | codex default: claude if installed, else codex
13
+ --brief <file> the brief default: none
14
+ --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
15
+ -- end of the flags; each later word joins the target
16
+
17
+ exit 0 pass: every criterion scored 4
18
+ exit 1 fail: every judge answered, and one or more criteria scored below 4
19
+ exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed
20
+ ```
21
+
22
+ 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.
23
+
24
+ ```sh
25
+ npx @xsft/panels run xsft/marketing-site https://example.com --brief docs/brief.md --harness claude
26
+ npx @xsft/panels run xsft/marketing-site/v6 . at http://localhost:4321 --sources docs --harness codex
27
+ ```
28
+
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 for each of its criteria.
30
+
31
+ ## Run folder
32
+
33
+ Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
34
+
35
+ - `panel.json`: the panel as the api gave it.
36
+ - `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.
38
+
39
+ The last line on stdout is the path of `report.json`.
40
+
41
+ ## Skipped
42
+
43
+ - Run upload to cross.software, and `--no-upload`.
44
+ - `--version`: `owner/name/vN` pins a version.
45
+ - `--out`: the run folder is fixed.
46
+ - `--json`: stdout ends with the path of `report.json`.
47
+ - `--concurrency`: fixed at 4.
48
+ - `--model`: each harness uses its default.
49
+ - A panel URL as `<panel>`, a `PANELS_API` override, and a panel cache.
50
+ - A login: an owner cannot run an unpublished panel.
51
+ - A limit on `--size`, cost totals, and `cap` and `gap` fields.
package/bin/panels.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
+ import { parse, SYNOPSIS } from '../lib/args.js';
5
+ import { runJudge } from '../lib/judge.js';
6
+ import { fetchPanel } from '../lib/panel.js';
7
+ import { buildReport, renderMarkdown } from '../lib/report.js';
8
+ import { split } from '../lib/split.js';
9
+
10
+ // More parallel sessions on one account get 429 answers from the model api.
11
+ const PARALLEL_JUDGES = 4;
12
+
13
+ const onPath = (name) => process.env.PATH.split(delimiter).some((dir) => existsSync(join(dir, name)));
14
+
15
+ async function pool(items, size, fn) {
16
+ const out = [];
17
+ let next = 0;
18
+ const worker = async () => {
19
+ while (next < items.length) {
20
+ const i = next++;
21
+ out[i] = await fn(items[i], i);
22
+ }
23
+ };
24
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
25
+ return out;
26
+ }
27
+
28
+ async function main() {
29
+ const args = parse(process.argv.slice(2));
30
+ if (args.help) {
31
+ console.log(SYNOPSIS);
32
+ return 0;
33
+ }
34
+ if (args.usage) {
35
+ console.error(SYNOPSIS);
36
+ return 2;
37
+ }
38
+ if (args.error) {
39
+ console.error(`panels: ${args.error}`);
40
+ return 2;
41
+ }
42
+ const harness = args.harness ?? (onPath('claude') ? 'claude' : 'codex');
43
+ if (!onPath(harness)) {
44
+ console.error(`panels: harness missing: ${harness} is not on PATH`);
45
+ return 2;
46
+ }
47
+ const fetched = await fetchPanel(args.panel);
48
+ if (fetched.error) {
49
+ console.error(`panels: ${fetched.error}`);
50
+ return 2;
51
+ }
52
+ const { panel } = fetched;
53
+ const cwd = process.cwd();
54
+ const judges = split(panel.criteria.length, args.size ?? panel.criteria.length);
55
+ const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, new Date().toISOString().replaceAll(':', '-'));
56
+ mkdirSync(runDir, { recursive: true });
57
+ writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
58
+ const outcomes = await pool(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
59
+ dir: join(runDir, 'judges', String(j + 1)),
60
+ criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
61
+ harness,
62
+ cwd,
63
+ target: args.target,
64
+ brief: args.brief,
65
+ sources: args.sources,
66
+ }));
67
+ const report = buildReport({ panel, target: args.target, cwd, harness, judges, outcomes });
68
+ const markdown = renderMarkdown(report);
69
+ const reportPath = join(runDir, 'report.json');
70
+ writeFileSync(join(runDir, 'report.md'), markdown);
71
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
72
+ console.log(markdown);
73
+ console.log(reportPath);
74
+ return { pass: 0, fail: 1, error: 2 }[report.status];
75
+ }
76
+
77
+ // Node exits 1 on a crash, and 1 means "fail" to an agent loop, so a crash prints its stack and exits 2.
78
+ main().then((code) => { process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 2; });
package/lib/args.js ADDED
@@ -0,0 +1,52 @@
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
+ --size <n> number of judges default: the number of criteria
9
+ --harness <name> claude | codex default: claude if installed, else codex
10
+ --brief <file> the brief default: none
11
+ --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
12
+ -- end of the flags; each later word joins the target
13
+
14
+ exit 0 pass: every criterion scored 4
15
+ exit 1 fail: every judge answered, and one or more criteria scored below 4
16
+ exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed`;
17
+
18
+ const options = {
19
+ size: { type: 'string' },
20
+ harness: { type: 'string' },
21
+ brief: { type: 'string' },
22
+ sources: { type: 'string', multiple: true },
23
+ help: { type: 'boolean' },
24
+ };
25
+
26
+ const PANEL = /^([a-z0-9-]+)\/([a-z0-9-]+)(?:\/v([1-9]\d*))?$/;
27
+
28
+ /** Returns the run options, { help: true }, { usage: true }, or { error } with a one-line message. */
29
+ export function parse(argv) {
30
+ // strict is off so that an unknown flag comes back as a token and gets the one-line message below.
31
+ const { values, positionals, tokens } = parseArgs({ args: argv, options, strict: false, allowPositionals: true, tokens: true });
32
+ for (const token of tokens.filter((t) => t.kind === 'option')) {
33
+ if (!Object.hasOwn(options, token.name)) return { error: `unknown flag ${token.rawName}` };
34
+ if (token.name !== 'help' && token.value === undefined) return { error: `${token.rawName} needs a value` };
35
+ }
36
+ if (values.help) return { help: true };
37
+ const [command, panel, ...words] = positionals;
38
+ if (command !== 'run' || panel === undefined) return { usage: true };
39
+ const match = PANEL.exec(panel);
40
+ if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name/vN` };
41
+ 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
+ if (values.harness !== undefined && !['claude', 'codex'].includes(values.harness)) return { error: `--harness needs claude or codex, not ${values.harness}` };
44
+ return {
45
+ panel: { owner: match[1], name: match[2], version: match[3] && Number(match[3]) },
46
+ target: words.join(' '),
47
+ size: values.size && Number(values.size),
48
+ harness: values.harness,
49
+ brief: values.brief,
50
+ sources: values.sources ?? [],
51
+ };
52
+ }
package/lib/judge.js ADDED
@@ -0,0 +1,145 @@
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
+
5
+ const { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
6
+ const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
7
+ const MINUTES_PER_CRITERION = 20;
8
+ const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
9
+ // With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
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:*)';
11
+
12
+ export const bandToScore = (band) => (band <= 3 ? 1 : band <= 6 ? 2 : band <= 8 ? 3 : 4);
13
+
14
+ /**
15
+ * The user message of a judge. criteria items are { rank, name, description, body }. Each part sits
16
+ * in a tag, because a body is markdown with headings of its own.
17
+ */
18
+ export function userMessage(criteria, { target, brief, sources }) {
19
+ const parts = criteria.map((c) => `<criterion rank="${c.rank}" name="${c.name}">\nDescription: ${c.description}\n\n${c.body}\n</criterion>`);
20
+ parts.push(`<target>\n${target}\n</target>`, `<brief>\n${brief ?? 'none supplied'}\n</brief>`, `<sources>\n${sources.length ? sources.join('\n') : 'none'}\n</sources>`);
21
+ return `${parts.join('\n\n')}\n`;
22
+ }
23
+
24
+ /** The output schema of a judge: { results }, one judgeResult for each of its criteria, with rank and name fixed. */
25
+ export function judgeSchema(criteria) {
26
+ const items = criteria.map((c) => ({
27
+ ...judgeResult,
28
+ properties: { ...judgeResult.properties, rank: { type: 'integer', const: c.rank }, criterion: { type: 'string', const: c.name } },
29
+ }));
30
+ return {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ required: ['results'],
34
+ properties: { results: { type: 'array', minItems: items.length, maxItems: items.length, items: items.length === 1 ? items[0] : { anyOf: items } } },
35
+ };
36
+ }
37
+
38
+ /** Returns the reasons the answer of a judge is not valid, or an empty array. */
39
+ export function check(answer, criteria) {
40
+ if (!Array.isArray(answer?.results)) return ['The answer must be a JSON object with a results array.'];
41
+ if (answer.results.length !== criteria.length) return [`results must hold ${criteria.length} items, one for each criterion, in rank order.`];
42
+ return criteria.flatMap((c, i) => {
43
+ const r = answer.results[i];
44
+ const missing = judgeResult.required.filter((key) => !(key in Object(r)));
45
+ if (missing.length) return [`Item ${i + 1} lacks ${missing.join(', ')}.`];
46
+ const errors = [];
47
+ if (r.rank !== c.rank || r.criterion !== c.name) errors.push(`Item ${i + 1} must have rank ${c.rank} and criterion "${c.name}".`);
48
+ if (!Number.isInteger(r.band) || r.band < 1 || r.band > 10) errors.push(`Rank ${c.rank}: band must be a whole number from 1 to 10.`);
49
+ else if (r.score !== bandToScore(r.band)) errors.push(`Rank ${c.rank}: band ${r.band} maps to score ${bandToScore(r.band)}, not ${r.score}.`);
50
+ if (r.score < 4 && !(Array.isArray(r.fixes) && r.fixes.length)) errors.push(`Rank ${c.rank}: score ${r.score} is below 4, so fixes must list each fix that score 4 needs.`);
51
+ return errors;
52
+ });
53
+ }
54
+
55
+ /** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
56
+ export function addDirs(cwd, brief, sources) {
57
+ const paths = [brief && dirname(resolve(cwd, brief)), ...sources.filter((s) => !/^https?:\/\//.test(s)).map((s) => resolve(cwd, s))];
58
+ return [...new Set(paths.filter(Boolean))].filter((p) => relative(cwd, p).split(sep)[0] === '..');
59
+ }
60
+
61
+ function exec(command, args, { cwd, stdin, timeout }) {
62
+ return new Promise((done) => {
63
+ const child = spawn(command, args, { cwd, timeout, stdio: [stdin, 'pipe', 'pipe'] });
64
+ let out = '';
65
+ let err = '';
66
+ child.stdout.on('data', (d) => { out += d; });
67
+ child.stderr.on('data', (d) => { err += d; });
68
+ child.on('close', (code, signal) => {
69
+ if (signal) done({ error: `${command} stopped at the time limit of ${timeout / 60_000} minutes` });
70
+ else if (code) done({ error: `${command} exited ${code}: ${(err || out).trim().slice(-1000)}` });
71
+ else done({ out });
72
+ });
73
+ });
74
+ }
75
+
76
+ async function askClaude(j, prompt, session) {
77
+ const args = [
78
+ '-p', '--safe-mode',
79
+ '--tools', TOOLS,
80
+ '--allowedTools', ALLOWED_TOOLS,
81
+ '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
82
+ ...j.addDirs.flatMap((d) => ['--add-dir', d]),
83
+ '--system-prompt', SYSTEM_PROMPT,
84
+ '--json-schema', JSON.stringify(j.schema),
85
+ '--output-format', 'json',
86
+ ...(session ? ['--resume', session] : []),
87
+ prompt,
88
+ ];
89
+ const run = await exec(j.command, args, { cwd: j.cwd, stdin: 'ignore', timeout: j.timeout });
90
+ if (run.error) return run;
91
+ writeFileSync(join(j.dir, 'raw.json'), run.out);
92
+ // With verbose on in the user settings, the output is an array of messages and not one result object.
93
+ const result = [JSON.parse(run.out)].flat().find((m) => m.type === 'result');
94
+ if (result.is_error) return { error: `claude answered an error: ${result.result}` };
95
+ return { answer: result.structured_output, session: result.session_id };
96
+ }
97
+
98
+ // --disable apps: the apps feature gives Codex the connectors of the ChatGPT account, and some of them write
99
+ // (chatgpt_space_create_page, chatgpt_space_trash_page). --ignore-user-config does not turn them off.
100
+ async function askCodex(j, prompt, thread) {
101
+ const raw = join(j.dir, 'raw.json');
102
+ const instructions = `developer_instructions=${JSON.stringify(SYSTEM_PROMPT)}`;
103
+ const output = ['--output-schema', join(j.dir, 'schema.json'), '-o', raw, '--json'];
104
+ const args = thread
105
+ ? ['exec', 'resume', thread, prompt, '--skip-git-repo-check', '--ignore-user-config', '-c', 'sandbox_mode="read-only"', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output]
106
+ : ['exec', '-', '-C', j.cwd, '--skip-git-repo-check', '--ignore-user-config', '-s', 'read-only', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output];
107
+ const stdin = thread ? 'ignore' : openSync(join(j.dir, 'prompt.md'));
108
+ const run = await exec(j.command, args, { cwd: j.cwd, stdin, timeout: j.timeout });
109
+ if (!thread) closeSync(stdin);
110
+ if (run.error) return run;
111
+ const started = run.out.split('\n').find((line) => line.includes('"thread.started"'));
112
+ return { answer: parseAnswer(readFileSync(raw, 'utf8')), session: thread ?? JSON.parse(started).thread_id };
113
+ }
114
+
115
+ function parseAnswer(text) {
116
+ try {
117
+ return JSON.parse(text);
118
+ } catch {
119
+ // check() rejects undefined, so text that is not JSON gets the one re-ask.
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
126
+ * Returns { results } or { error }. command is the harness binary; the tests pass a stub.
127
+ */
128
+ export async function runJudge({ dir, criteria, harness, cwd, target, brief, sources }, command = harness) {
129
+ mkdirSync(dir, { recursive: true });
130
+ const prompt = userMessage(criteria, { target, brief, sources });
131
+ const schema = judgeSchema(criteria);
132
+ writeFileSync(join(dir, 'prompt.md'), prompt);
133
+ writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
134
+ const j = { dir, cwd, schema, command, addDirs: addDirs(cwd, brief, sources), timeout: criteria.length * MINUTES_PER_CRITERION * 60_000 };
135
+ const ask = harness === 'claude' ? askClaude : askCodex;
136
+ let reply = await ask(j, prompt);
137
+ if (reply.error) return reply;
138
+ let errors = check(reply.answer, criteria);
139
+ if (errors.length) {
140
+ reply = await ask(j, `Your answer failed these checks. Return the whole JSON object again, with each one fixed:\n${errors.map((e) => `- ${e}`).join('\n')}`, reply.session);
141
+ if (reply.error) return reply;
142
+ errors = check(reply.answer, criteria);
143
+ }
144
+ return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
145
+ }
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/split.js ADDED
@@ -0,0 +1,14 @@
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
+ }
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.1.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
+ }