@xsft/panels 0.6.0 → 0.7.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 CHANGED
@@ -11,6 +11,8 @@ panels run <panel> [target...] [flags]
11
11
  --harness <name> claude | codex default: claude if installed, else codex
12
12
  --brief <file> the brief default: none
13
13
  --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
14
+ --yolo the judges skip every permission question (claude --dangerously-skip-permissions, codex --yolo).
15
+ Without it, a judge asks its own questions in its tab. Without a terminal, the flag is required.
14
16
  -- end of the flags; each later word joins the target
15
17
 
16
18
  exit 0 pass: every criterion scored 4
@@ -18,7 +20,7 @@ exit 1 fail: every judge answered, and one or more criteria scored below 4
18
20
  exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed
19
21
  ```
20
22
 
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.
23
+ CAUTION: A judge gets no tool block and no sandbox from the CLI. Without `--yolo`, the harness asks its own permission questions in the judge tab. With `--yolo`, nothing asks: the judge can read, write, run commands, and reach the network as you, and the system prompt (do not write or change files, no sub-agents, no forms, no posts) is the only guard. Do not run a panel on a repository that you do not trust.
22
24
 
23
25
  ```sh
24
26
  npx @xsft/panels run xsft/marketing-site https://example.com --brief docs/brief.md --harness claude
@@ -29,16 +31,18 @@ Run the command in the folder of the product. Each judge starts in that folder,
29
31
 
30
32
  ## Terminal mode
31
33
 
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`).
34
+ On a terminal, `panels run` shows the overview, and each judge runs in its interactive harness in a pseudo-terminal inside the command. Each judge writes `judges/N/answer.json` and stays open. The overview writes the report when each judge has an answer or an error, and the command exits from `report.json` when you quit. Without a terminal, the judges run in the background (`claude -p`, `codex exec`), and stdout prints `N/4 name` and the verdict of each criterion, then the report path. Plain mode needs `--yolo`, because a background judge cannot answer a question; without it the command exits 2.
33
35
 
34
- A tab strip on the last three lines shows a hairline, a tab per view, and a status line with the progress, the elapsed time, and the keys. The tabs are the scores, then each judge with its number, name, and mark (score, ✗ for an error, · while queued, ! while the harness asks whether to trust the folder). One soft wave sweeps across the strip and lights the labels of the running judges as it passes; the same wave sweeps the running rows of the scores table. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge, `0` to the scores, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The scores take no input, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
36
+ The overview fills the terminal: a header line with the run (panel and target at the left, the version, the harness, and the elapsed time at the right edge) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red, a grey `·/4` until the score arrives), the time the judge has run, and the tokens it used, with a total row at the bottom under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, the verdict paragraph, the one-line description and the body of the criterion as plain text, and at the bottom the key line and a grey fact line with the judge number, the harness, the time, and the answer file. `enter` moves the focus into that pane (an orange bar marks it): `↑` and `↓` scroll one line, `PgUp` and `PgDn` a page, `esc` or `enter` return to the list. While a judge runs, its detail shows the word judging in big letters (figlet, ANSI Shadow) with the wave over it and the time under it. One soft wave sweeps across the running rows. On the overview, `↑` and `↓` move the cursor, `r` restarts the judge of the cursor row with its harness (its answer is dropped and the report is written again when it finishes), and `h` flips that harness between `claude` and `codex`. The page never says pass or fail; the total is the result.
37
+
38
+ A tab strip on the last three lines shows a hairline, the tabs (`Overview`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
35
39
 
36
40
  ## Run folder
37
41
 
38
42
  Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
39
43
 
40
44
  - `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`.
45
+ - `report.json`: `{ panel: "owner/name@version", target, status, criteria: [{ name, score, verdict }] }`, in panel order. `schema/report.json` is its schema, and its `criterion` definition is the shape of the answer of a judge.
42
46
  - `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k. In terminal mode, `answer.json` takes the place of the raw answer.
43
47
 
44
48
  The last line on stdout is the path of `report.json`.
package/bin/panels.js CHANGED
@@ -4,7 +4,7 @@ import { delimiter, join } from 'node:path';
4
4
  import { parse, SYNOPSIS } from '../lib/args.js';
5
5
  import { runJudge } from '../lib/judge.js';
6
6
  import { fetchPanel } from '../lib/panel.js';
7
- import { buildReport, renderMarkdown } from '../lib/report.js';
7
+ import { buildReport, renderPlain } from '../lib/report.js';
8
8
  import { runTable } from '../lib/table.js';
9
9
 
10
10
  // More parallel sessions on one account get 429 answers from the model api.
@@ -29,7 +29,7 @@ const EXIT = { pass: 0, fail: 1, error: 2 };
29
29
 
30
30
  /** Runs the judges in the score table, and exits from report.json when the engineer quits. */
31
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 });
32
+ await runTable({ panel, runDir, harness, cwd, target: args.target, brief: args.brief, sources: args.sources, yolo: args.yolo });
33
33
  const reportPath = join(runDir, 'report.json');
34
34
  if (!existsSync(reportPath)) return 2;
35
35
  console.log(reportPath);
@@ -55,6 +55,11 @@ async function main() {
55
55
  console.error(`panels: harness missing: ${harness} is not on PATH`);
56
56
  return 2;
57
57
  }
58
+ // A judge without a terminal cannot answer a permission question.
59
+ if (!(process.stdout.isTTY && process.stdin.isTTY) && !args.yolo) {
60
+ console.error('panels: plain mode needs --yolo (judges cannot ask)');
61
+ return 2;
62
+ }
58
63
  const fetched = await fetchPanel(args.panel);
59
64
  if (fetched.error) {
60
65
  console.error(`panels: ${fetched.error}`);
@@ -62,27 +67,25 @@ async function main() {
62
67
  }
63
68
  const { panel } = fetched;
64
69
  const cwd = process.cwd();
65
- const judges = panel.criteria.map((_, i) => [i]);
66
70
  const time = new Date().toISOString().replaceAll(':', '-');
67
71
  const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, time);
68
72
  mkdirSync(runDir, { recursive: true });
69
73
  writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
70
74
  if (process.stdout.isTTY && process.stdin.isTTY) return runOnTerminal({ panel, runDir, harness, cwd, args });
71
- const outcomes = await pool(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
72
- dir: join(runDir, 'judges', String(j + 1)),
73
- criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
75
+ const outcomes = await pool(panel.criteria, PARALLEL_JUDGES, (c, i) => runJudge({
76
+ dir: join(runDir, 'judges', String(i + 1)),
77
+ criterion: { rank: i + 1, ...c },
74
78
  harness,
75
79
  cwd,
76
80
  target: args.target,
77
81
  brief: args.brief,
78
82
  sources: args.sources,
83
+ yolo: true,
79
84
  }));
80
- const report = buildReport({ panel, target: args.target, cwd, harness, judges, outcomes });
81
- const markdown = renderMarkdown(report);
85
+ const report = buildReport({ panel, target: args.target, outcomes });
82
86
  const reportPath = join(runDir, 'report.json');
83
- writeFileSync(join(runDir, 'report.md'), markdown);
84
87
  writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
85
- console.log(markdown);
88
+ console.log(renderPlain(report).join('\n'));
86
89
  console.log(reportPath);
87
90
  return EXIT[report.status];
88
91
  }
package/lib/args.js CHANGED
@@ -8,6 +8,8 @@ export const SYNOPSIS = `panels run <panel> [target...] [flags]
8
8
  --harness <name> claude | codex default: claude if installed, else codex
9
9
  --brief <file> the brief default: none
10
10
  --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
11
+ --yolo the judges skip every permission question (claude --dangerously-skip-permissions, codex --yolo).
12
+ Without it, a judge asks its own questions in its tab. Without a terminal, the flag is required.
11
13
  -- end of the flags; each later word joins the target
12
14
 
13
15
  exit 0 pass: every criterion scored 4
@@ -18,6 +20,7 @@ const options = {
18
20
  harness: { type: 'string' },
19
21
  brief: { type: 'string' },
20
22
  sources: { type: 'string', multiple: true },
23
+ yolo: { type: 'boolean' },
21
24
  help: { type: 'boolean' },
22
25
  };
23
26
 
@@ -29,7 +32,7 @@ export function parse(argv) {
29
32
  const { values, positionals, tokens } = parseArgs({ args: argv, options, strict: false, allowPositionals: true, tokens: true });
30
33
  for (const token of tokens.filter((t) => t.kind === 'option')) {
31
34
  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` };
35
+ if (options[token.name].type === 'string' && token.value === undefined) return { error: `${token.rawName} needs a value` };
33
36
  }
34
37
  if (values.help) return { help: true };
35
38
  const [command, panel, ...words] = positionals;
@@ -44,5 +47,6 @@ export function parse(argv) {
44
47
  harness: values.harness,
45
48
  brief: values.brief,
46
49
  sources: values.sources ?? [],
50
+ yolo: Boolean(values.yolo),
47
51
  };
48
52
  }
package/lib/judge.js CHANGED
@@ -2,51 +2,43 @@ import { spawn } from 'node:child_process';
2
2
  import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
 
5
- const { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
5
+ const { criterion: CRITERION } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
6
6
  const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
7
7
  export 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:*)';
8
+ // A judge gets no tool list, no permission mode, and no sandbox flag: the harness defaults, and its own questions
9
+ // in its tab. With --yolo, the judge skips every question: no sandbox, nothing is denied, nothing asks. The system
10
+ // prompt holds the rules on writing files, sub-agents, forms, and messages. exec resume takes no sandbox flag,
11
+ // so it gets the same setting as config values.
12
+ // codex --yolo is the alias of --dangerously-bypass-approvals-and-sandbox.
13
+ const yoloFlags = (yolo, harness) => (!yolo ? [] : harness === 'claude' ? ['--dangerously-skip-permissions'] : ['--yolo']);
14
+ const CODEX_RESUME_YOLO = ['-c', 'sandbox_mode="danger-full-access"', '-c', 'approval_policy="never"'];
11
15
 
12
16
  /**
13
- * The user message of a judge. criteria items are { rank, name, description, body }. Each part sits
14
- * in a tag, because a body is markdown with headings of its own.
17
+ * The user message of a judge. criterion is { rank, name, description, body }. Each part sits in a tag,
18
+ * because a body is markdown with headings of its own.
15
19
  */
16
- export function userMessage(criteria, { target, brief, sources }) {
17
- const parts = criteria.map((c) => `<criterion rank="${c.rank}" name="${c.name}">\nDescription: ${c.description}\n\n${c.body}\n</criterion>`);
20
+ export function userMessage(criterion, { target, brief, sources }) {
21
+ const parts = [`<criterion rank="${criterion.rank}" name="${criterion.name}">\nDescription: ${criterion.description}\n\n${criterion.body}\n</criterion>`];
18
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>`);
19
23
  return `${parts.join('\n\n')}\n`;
20
24
  }
21
25
 
22
- /** The output schema of a judge: { results }, one judgeResult for each of its criteria, with rank and name fixed. */
23
- export function judgeSchema(criteria) {
24
- const items = criteria.map((c) => ({
25
- ...judgeResult,
26
- properties: { ...judgeResult.properties, rank: { type: 'integer', const: c.rank }, criterion: { type: 'string', const: c.name } },
27
- }));
28
- return {
29
- type: 'object',
30
- additionalProperties: false,
31
- required: ['results'],
32
- properties: { results: { type: 'array', minItems: items.length, maxItems: items.length, items: items.length === 1 ? items[0] : { anyOf: items } } },
33
- };
26
+ /** The output schema of a judge: one criterion object { name, score, verdict }, with the name fixed. */
27
+ export function judgeSchema(criterion) {
28
+ const { description, ...schema } = CRITERION;
29
+ return { ...schema, properties: { ...schema.properties, name: { type: 'string', const: criterion.name } } };
34
30
  }
35
31
 
36
32
  /** Returns the reasons the answer of a judge is not valid, or an empty array. */
37
- export function check(answer, criteria) {
38
- if (!Array.isArray(answer?.results)) return ['The answer must be a JSON object with a results array.'];
39
- if (answer.results.length !== criteria.length) return [`results must hold ${criteria.length} items, one for each criterion, in rank order.`];
40
- return criteria.flatMap((c, i) => {
41
- const r = answer.results[i];
42
- const missing = judgeResult.required.filter((key) => !(key in Object(r)));
43
- if (missing.length) return [`Item ${i + 1} lacks ${missing.join(', ')}.`];
44
- const errors = [];
45
- if (r.rank !== c.rank || r.criterion !== c.name) errors.push(`Item ${i + 1} must have rank ${c.rank} and criterion "${c.name}".`);
46
- if (!Number.isInteger(r.score) || r.score < 1 || r.score > 4) errors.push(`Rank ${c.rank}: score must be a whole number from 1 to 4.`);
47
- if (r.score < 4 && !(Array.isArray(r.fixes) && r.fixes.length)) errors.push(`Rank ${c.rank}: score ${r.score} is below 4, so fixes must list each fix that score 4 needs.`);
48
- return errors;
49
- });
33
+ export function check(answer, criterion) {
34
+ if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) return ['The answer must be one JSON object with name, score, and verdict.'];
35
+ const missing = CRITERION.required.filter((key) => !(key in answer));
36
+ if (missing.length) return [`The answer lacks ${missing.join(', ')}.`];
37
+ const errors = [];
38
+ if (answer.name !== criterion.name) errors.push(`name must be "${criterion.name}".`);
39
+ if (!Number.isInteger(answer.score) || answer.score < 1 || answer.score > 4) errors.push('score must be a whole number from 1 to 4.');
40
+ if (typeof answer.verdict !== 'string' || !answer.verdict.trim()) errors.push('verdict must be one paragraph of text.');
41
+ return errors;
50
42
  }
51
43
 
52
44
  /** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
@@ -72,10 +64,7 @@ function exec(command, args, { cwd, stdin, timeout }) {
72
64
 
73
65
  async function askClaude(j, prompt, session) {
74
66
  const args = [
75
- '-p', '--safe-mode',
76
- '--tools', TOOLS,
77
- '--allowedTools', ALLOWED_TOOLS,
78
- '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
67
+ '-p', ...yoloFlags(j.yolo, 'claude'),
79
68
  ...j.addDirs.flatMap((d) => ['--add-dir', d]),
80
69
  '--system-prompt', SYSTEM_PROMPT,
81
70
  '--json-schema', JSON.stringify(j.schema),
@@ -92,15 +81,13 @@ async function askClaude(j, prompt, session) {
92
81
  return { answer: result.structured_output, session: result.session_id };
93
82
  }
94
83
 
95
- // --disable apps: the apps feature gives Codex the connectors of the ChatGPT account, and some of them write
96
- // (chatgpt_space_create_page, chatgpt_space_trash_page). --ignore-user-config does not turn them off.
97
84
  async function askCodex(j, prompt, thread) {
98
85
  const raw = join(j.dir, 'raw.json');
99
86
  const instructions = `developer_instructions=${JSON.stringify(SYSTEM_PROMPT)}`;
100
87
  const output = ['--output-schema', join(j.dir, 'schema.json'), '-o', raw, '--json'];
101
88
  const args = thread
102
- ? ['exec', 'resume', thread, prompt, '--skip-git-repo-check', '--ignore-user-config', '-c', 'sandbox_mode="read-only"', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output]
103
- : ['exec', '-', '-C', j.cwd, '--skip-git-repo-check', '--ignore-user-config', '-s', 'read-only', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output];
89
+ ? ['exec', 'resume', thread, prompt, '--skip-git-repo-check', '--ignore-user-config', ...(j.yolo ? CODEX_RESUME_YOLO : []), '-c', instructions, ...output]
90
+ : ['exec', '-', '-C', j.cwd, '--skip-git-repo-check', '--ignore-user-config', ...yoloFlags(j.yolo, 'codex'), '-c', instructions, ...output];
104
91
  const stdin = thread ? 'ignore' : openSync(join(j.dir, 'prompt.md'));
105
92
  const run = await exec(j.command, args, { cwd: j.cwd, stdin, timeout: j.timeout });
106
93
  if (!thread) closeSync(stdin);
@@ -120,25 +107,25 @@ export function parseAnswer(text) {
120
107
 
121
108
  /**
122
109
  * Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
123
- * Returns { results } or { error }. command is the harness binary; the tests pass a stub.
110
+ * Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
124
111
  */
125
- export async function runJudge({ dir, criteria, harness, cwd, target, brief, sources }, command = harness) {
112
+ export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources, yolo = false }, command = harness) {
126
113
  mkdirSync(dir, { recursive: true });
127
- const prompt = userMessage(criteria, { target, brief, sources });
128
- const schema = judgeSchema(criteria);
114
+ const prompt = userMessage(criterion, { target, brief, sources });
115
+ const schema = judgeSchema(criterion);
129
116
  writeFileSync(join(dir, 'prompt.md'), prompt);
130
117
  writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
131
- const j = { dir, cwd, schema, command, addDirs: addDirs(cwd, brief, sources), timeout: criteria.length * MINUTES_PER_CRITERION * 60_000 };
118
+ const j = { dir, cwd, schema, command, yolo, addDirs: addDirs(cwd, brief, sources), timeout: MINUTES_PER_CRITERION * 60_000 };
132
119
  const ask = harness === 'claude' ? askClaude : askCodex;
133
120
  let reply = await ask(j, prompt);
134
121
  if (reply.error) return reply;
135
- let errors = check(reply.answer, criteria);
122
+ let errors = check(reply.answer, criterion);
136
123
  if (errors.length) {
137
124
  reply = await ask(j, `Your answer failed these checks. Return the whole JSON object again, with each one fixed:\n${errors.map((e) => `- ${e}`).join('\n')}`, reply.session);
138
125
  if (reply.error) return reply;
139
- errors = check(reply.answer, criteria);
126
+ errors = check(reply.answer, criterion);
140
127
  }
141
- return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
128
+ return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { answer: reply.answer };
142
129
  }
143
130
 
144
131
  const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
@@ -150,19 +137,17 @@ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOM
150
137
  * Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
151
138
  * arguments that start the judge in its interactive harness.
152
139
  */
153
- export function writeJudge({ dir, criteria, harness, cwd, target, brief, sources }) {
140
+ export function writeJudge({ dir, criterion, harness, cwd, target, brief, sources, yolo = false }) {
154
141
  mkdirSync(dir, { recursive: true });
155
- const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer JSON, which matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Write that file yourself with a tool, and do not start sub-agents: the JSON in your reply does not count. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
156
- const prompt = `${userMessage(criteria, { target, brief, sources })}\n${closing}`;
142
+ const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer, one JSON object { name, score, verdict } that matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Write that file yourself with a tool, and do not start sub-agents: the JSON in your reply does not count. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
143
+ const prompt = `${userMessage(criterion, { target, brief, sources })}\n${closing}`;
157
144
  writeFileSync(join(dir, 'prompt.md'), prompt);
158
- writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
145
+ writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criterion), null, 2));
159
146
  if (harness === 'codex') {
160
147
  // Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
161
148
  // write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
162
- return ['codex', '-C', dir, '-s', 'workspace-write', '--disable', 'multi_agent', '--disable', 'apps', '-c', `developer_instructions=${tomlString(`${SYSTEM_PROMPT}\n${closing}`)}`, prompt];
149
+ return ['codex', '-C', dir, ...yoloFlags(yolo, 'codex'), '-c', `developer_instructions=${tomlString(`${SYSTEM_PROMPT}\n${closing}`)}`, prompt];
163
150
  }
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
151
  const add = addDirs(cwd, brief, sources).flatMap((d) => ['--add-dir', d]);
167
- return ['claude', '--safe-mode', '--system-prompt', SYSTEM_PROMPT, '--tools', `${TOOLS},Write`, ...add, '--allowedTools', allowed, '--permission-mode', 'dontAsk', prompt];
152
+ return ['claude', ...yoloFlags(yolo, 'claude'), '--system-prompt', SYSTEM_PROMPT, ...add, prompt];
168
153
  }
package/lib/report.js CHANGED
@@ -1,45 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /** The version of this package, from package.json. */
4
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
5
+
1
6
  /**
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.
7
+ * Builds report.json: { panel: "owner/name@version", target, status, criteria }. outcomes holds the
8
+ * { answer } or { error } of the judge of each criterion, in panel order. A criterion whose judge failed
9
+ * gets score null and the error as its verdict, and the run status is error. The panel passes only when
10
+ * every criterion scores 4.
5
11
  */
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 })));
12
+ export function buildReport({ panel, target, outcomes }) {
10
13
  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 };
14
+ const { answer, error } = outcomes[i];
15
+ return answer ? { name: c.name, score: answer.score, verdict: answer.verdict } : { name: c.name, score: null, verdict: `error: ${error}` };
13
16
  });
14
17
  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 };
18
+ return { panel: `${panel.owner}/${panel.name}@${panel.version}`, target, status, criteria };
16
19
  }
17
20
 
18
- const LEVELS = { 1: 'Fails', 2: 'Falls short', 3: 'Almost met', 4: 'Met' };
19
-
20
- const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
21
-
22
- export function renderMarkdown(report) {
23
- const { status, panel, target, criteria, errors } = report;
24
- const lines = [
25
- `${status.toUpperCase()} ${panel.owner}/${panel.name} v${panel.version} on ${JSON.stringify(target)} (${criteria.filter((c) => c.score === 4).length} of ${criteria.length} criteria at 4)`,
26
- '',
27
- '| # | Criterion | Score | Verdict |',
28
- '|---|---|---|---|',
29
- ];
30
- for (const c of criteria) {
31
- const lowest = c.judges.find((a) => a.result.score === c.score);
32
- lines.push(`| ${c.rank} | ${cell(c.name)} | ${c.score ?? 'error'} | ${cell(lowest?.result.verdict ?? '')} |`);
33
- }
34
- const list = (title, items) => items.length && lines.push(title, ...items.map((item) => `- ${item}`));
35
- for (const c of criteria) {
36
- for (const { result } of c.judges) {
37
- lines.push('', `## ${c.rank}. ${c.name}: ${result.score}/4 ${LEVELS[result.score]}`);
38
- list('Evidence', result.evidence.map((e) => `${e.where}: ${e.quote ?? e.claim}`));
39
- list('Fixes', result.fixes.map((f) => `${f.problem} ${f.change} (${f.where})`));
40
- list('Not checked', result.notChecked.map((n) => `${n.test}: ${n.reason}`));
41
- }
42
- }
43
- if (errors.length) lines.push('', '## Errors', ...errors.map((e) => `- judge ${e.judge}: ${e.message}`));
44
- return `${lines.join('\n')}\n`;
21
+ /** The lines that the plain mode prints: the run and the version, then "N/4 name" and the verdict under it, for each criterion. */
22
+ export function renderPlain(report) {
23
+ return [`panels ${VERSION} ${report.panel} ${report.target}`, '', ...report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, ''])];
45
24
  }