@xsft/panels 0.7.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,11 +31,11 @@ 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 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.
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
- The overview has a header line with the run (panel, target, harness, elapsed time) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red), the time the judge has run, and the tokens it used, with a total row under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, the verdict paragraph, the key line, and a grey fact line with the judge number, the harness, the time, and the answer file. One soft wave sweeps across the running rows. On the overview, `↑` and `↓` move the cursor, `r` restarts the judge of the cursor row with its harness (its answer is dropped and the report is written again when it finishes), and `h` flips that harness between `claude` and `codex`. The page never says pass or fail; the total is the result.
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.
35
37
 
36
- A tab strip on the last three lines shows a hairline, the tabs (`overview`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
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.
37
39
 
38
40
  ## Run folder
39
41
 
package/bin/panels.js CHANGED
@@ -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}`);
@@ -75,6 +80,7 @@ async function main() {
75
80
  target: args.target,
76
81
  brief: args.brief,
77
82
  sources: args.sources,
83
+ yolo: true,
78
84
  }));
79
85
  const report = buildReport({ panel, target: args.target, outcomes });
80
86
  const reportPath = join(runDir, 'report.json');
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
@@ -3,12 +3,15 @@ import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'nod
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
 
5
5
  const { criterion: CRITERION } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
6
- const VERDICT_LENGTH = CRITERION.properties.verdict.maxLength;
7
6
  const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
8
7
  export const MINUTES_PER_CRITERION = 20;
9
- const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
10
- // With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
11
- const ALLOWED_TOOLS = 'Read,Glob,Grep,WebFetch,WebSearch,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git status:*),Bash(git ls-files:*),Bash(git rev-parse:*),Bash(git branch --list:*)';
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"'];
12
15
 
13
16
  /**
14
17
  * The user message of a judge. criterion is { rank, name, description, body }. Each part sits in a tag,
@@ -35,7 +38,6 @@ export function check(answer, criterion) {
35
38
  if (answer.name !== criterion.name) errors.push(`name must be "${criterion.name}".`);
36
39
  if (!Number.isInteger(answer.score) || answer.score < 1 || answer.score > 4) errors.push('score must be a whole number from 1 to 4.');
37
40
  if (typeof answer.verdict !== 'string' || !answer.verdict.trim()) errors.push('verdict must be one paragraph of text.');
38
- else if (answer.verdict.length > VERDICT_LENGTH) errors.push(`verdict must be ${VERDICT_LENGTH} characters at most; it has ${answer.verdict.length}.`);
39
41
  return errors;
40
42
  }
41
43
 
@@ -62,10 +64,7 @@ function exec(command, args, { cwd, stdin, timeout }) {
62
64
 
63
65
  async function askClaude(j, prompt, session) {
64
66
  const args = [
65
- '-p', '--safe-mode',
66
- '--tools', TOOLS,
67
- '--allowedTools', ALLOWED_TOOLS,
68
- '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
67
+ '-p', ...yoloFlags(j.yolo, 'claude'),
69
68
  ...j.addDirs.flatMap((d) => ['--add-dir', d]),
70
69
  '--system-prompt', SYSTEM_PROMPT,
71
70
  '--json-schema', JSON.stringify(j.schema),
@@ -82,15 +81,13 @@ async function askClaude(j, prompt, session) {
82
81
  return { answer: result.structured_output, session: result.session_id };
83
82
  }
84
83
 
85
- // --disable apps: the apps feature gives Codex the connectors of the ChatGPT account, and some of them write
86
- // (chatgpt_space_create_page, chatgpt_space_trash_page). --ignore-user-config does not turn them off.
87
84
  async function askCodex(j, prompt, thread) {
88
85
  const raw = join(j.dir, 'raw.json');
89
86
  const instructions = `developer_instructions=${JSON.stringify(SYSTEM_PROMPT)}`;
90
87
  const output = ['--output-schema', join(j.dir, 'schema.json'), '-o', raw, '--json'];
91
88
  const args = thread
92
- ? ['exec', 'resume', thread, prompt, '--skip-git-repo-check', '--ignore-user-config', '-c', 'sandbox_mode="read-only"', '--disable', 'multi_agent', '--disable', 'apps', '-c', instructions, ...output]
93
- : ['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];
94
91
  const stdin = thread ? 'ignore' : openSync(join(j.dir, 'prompt.md'));
95
92
  const run = await exec(j.command, args, { cwd: j.cwd, stdin, timeout: j.timeout });
96
93
  if (!thread) closeSync(stdin);
@@ -112,13 +109,13 @@ export function parseAnswer(text) {
112
109
  * Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
113
110
  * Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
114
111
  */
115
- export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources }, command = harness) {
112
+ export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources, yolo = false }, command = harness) {
116
113
  mkdirSync(dir, { recursive: true });
117
114
  const prompt = userMessage(criterion, { target, brief, sources });
118
115
  const schema = judgeSchema(criterion);
119
116
  writeFileSync(join(dir, 'prompt.md'), prompt);
120
117
  writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
121
- const j = { dir, cwd, schema, command, addDirs: addDirs(cwd, brief, sources), timeout: MINUTES_PER_CRITERION * 60_000 };
118
+ const j = { dir, cwd, schema, command, yolo, addDirs: addDirs(cwd, brief, sources), timeout: MINUTES_PER_CRITERION * 60_000 };
122
119
  const ask = harness === 'claude' ? askClaude : askCodex;
123
120
  let reply = await ask(j, prompt);
124
121
  if (reply.error) return reply;
@@ -140,7 +137,7 @@ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOM
140
137
  * Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
141
138
  * arguments that start the judge in its interactive harness.
142
139
  */
143
- export function writeJudge({ dir, criterion, harness, cwd, target, brief, sources }) {
140
+ export function writeJudge({ dir, criterion, harness, cwd, target, brief, sources, yolo = false }) {
144
141
  mkdirSync(dir, { recursive: true });
145
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`;
146
143
  const prompt = `${userMessage(criterion, { target, brief, sources })}\n${closing}`;
@@ -149,10 +146,8 @@ export function writeJudge({ dir, criterion, harness, cwd, target, brief, source
149
146
  if (harness === 'codex') {
150
147
  // Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
151
148
  // write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
152
- 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];
153
150
  }
154
- // Claude matches file paths only in Edit rules, which cover Write too; // starts an absolute path.
155
- const allowed = `${ALLOWED_TOOLS},Edit(/${join(dir, 'answer.json')})`;
156
151
  const add = addDirs(cwd, brief, sources).flatMap((d) => ['--add-dir', d]);
157
- 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];
158
153
  }
package/lib/report.js CHANGED
@@ -1,3 +1,8 @@
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
7
  * Builds report.json: { panel: "owner/name@version", target, status, criteria }. outcomes holds the
3
8
  * { answer } or { error } of the judge of each criterion, in panel order. A criterion whose judge failed
@@ -13,7 +18,7 @@ export function buildReport({ panel, target, outcomes }) {
13
18
  return { panel: `${panel.owner}/${panel.name}@${panel.version}`, target, status, criteria };
14
19
  }
15
20
 
16
- /** The lines that the plain mode prints: "N/4 name" and the verdict under it, for each criterion. */
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. */
17
22
  export function renderPlain(report) {
18
- return report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, '']);
23
+ return [`panels ${VERSION} ${report.panel} ${report.target}`, '', ...report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, ''])];
19
24
  }
package/lib/table.js CHANGED
@@ -5,8 +5,9 @@ import { join } from 'node:path';
5
5
  import pty from '@homebridge/node-pty-prebuilt-multiarch';
6
6
  import { SerializeAddon } from '@xterm/addon-serialize';
7
7
  import xterm from '@xterm/headless';
8
+ import figlet from 'figlet';
8
9
  import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
9
- import { buildReport } from './report.js';
10
+ import { buildReport, VERSION } from './report.js';
10
11
 
11
12
  const LIVE_JUDGES = 10;
12
13
  // A harness asks this in a folder it has not seen. Only the first judge starts until its screen no longer asks,
@@ -75,6 +76,16 @@ export function tokensOnScreen(text) {
75
76
  return best;
76
77
  }
77
78
 
79
+ let artLines;
80
+ /** The word "judging" as big letters, in the ANSI Shadow font (Standard when that font is missing). Rendered once. */
81
+ export function art() {
82
+ if (!artLines) {
83
+ const font = figlet.fontsSync().includes('ANSI Shadow') ? 'ANSI Shadow' : 'Standard';
84
+ artLines = figlet.textSync('judging', { font }).split('\n').filter((l) => l.trim());
85
+ }
86
+ return artLines;
87
+ }
88
+
78
89
  const plainLength = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
79
90
  const padTo = (s, width) => s + ' '.repeat(Math.max(0, width - plainLength(s)));
80
91
 
@@ -107,17 +118,95 @@ export function wrap(text, width) {
107
118
  const quarters = (sum) => String(Math.round((sum / 4) * 100) / 100);
108
119
 
109
120
  /**
110
- * The lines of the overview. rows hold { rank, name, state, result, error, trust, time, tokens, harness, restarted },
111
- * and state is queued, running, done, or error. time is the seconds the judge has run, tokens the count it used
112
- * (null for none), harness the one its next restart uses, restarted the seconds since a restart (null for none).
113
- * cursor is the index of the row whose detail shows at the right. The header line holds the run: panel, target,
114
- * harness, elapsed. Two columns from TWO_COLUMNS wide, the list above the detail below that.
121
+ * The body of a criterion as plain lines for the detail pane: a heading becomes a grey upper-case label, a list
122
+ * item keeps its dash, a fenced block is indented four spaces as it is, a table line stays as it is, and the
123
+ * other text is wrapped to width with the emphasis, code, and link marks removed.
124
+ */
125
+ export function plainMarkdown(markdown, width) {
126
+ const inline = (t) => t.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/\*(\S[^*]*?)\*/g, '$1').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
127
+ const lines = [];
128
+ let paragraph = [];
129
+ let fence = false;
130
+ const flush = () => {
131
+ if (paragraph.length) lines.push(...wrap(inline(paragraph.join(' ')), width));
132
+ paragraph = [];
133
+ };
134
+ for (const raw of markdown.split('\n')) {
135
+ const line = raw.trimEnd();
136
+ if (/^\s*```/.test(line)) { flush(); fence = !fence; continue; }
137
+ if (fence) { lines.push(` ${line}`); continue; }
138
+ if (!line.trim()) { flush(); if (lines.at(-1) !== '') lines.push(''); continue; }
139
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
140
+ if (heading) { flush(); if (lines.length && lines.at(-1) !== '') lines.push(''); lines.push(`${GREY}${inline(heading[1]).toUpperCase()}${RESET}`); continue; }
141
+ const item = /^\s*(?:[-*+]|\d+\.)\s+(.*)$/.exec(line);
142
+ if (item) { flush(); const [first, ...rest] = wrap(inline(item[1]), width - 2); lines.push(`- ${first ?? ''}`, ...rest.map((l) => ` ${l}`)); continue; }
143
+ if (/^\s*\|/.test(line)) { flush(); lines.push(inline(line)); continue; }
144
+ paragraph.push(line.trim());
145
+ }
146
+ flush();
147
+ while (lines.at(-1) === '') lines.pop();
148
+ while (lines[0] === '') lines.shift();
149
+ return lines;
150
+ }
151
+
152
+ /** The lines of the detail pane under its header: the verdict or the judging art, then the criterion text. */
153
+ function detailContent(row, columnWidth, start, width, frame) {
154
+ const lines = [''];
155
+ if (row.state === 'running' && !row.trust) {
156
+ // The judge works: the word judging in big letters with the wave over it, or in one line in a narrow pane.
157
+ const letters = art();
158
+ lines.push(...(letters[0].length <= columnWidth ? letters : ['judging']).map((l) => glimmer(l, frame, start, width)));
159
+ const since = row.restarted !== null ? `restarted with ${row.harness} · ` : '';
160
+ lines.push(`${GREY}${since}${clock(row.time ?? 0)} · ${row.rank} to watch${RESET}`);
161
+ } else {
162
+ const text = row.state === 'done' ? row.result.verdict
163
+ : row.state === 'error' ? row.error
164
+ : row.state === 'queued' ? 'The judge has not started.'
165
+ : `Press ${row.rank} and answer the trust prompt.`;
166
+ lines.push(...wrap(text, columnWidth));
167
+ }
168
+ if (row.description) lines.push('', ...wrap(row.description, columnWidth));
169
+ if (row.body) lines.push('', ...plainMarkdown(row.body, columnWidth));
170
+ return lines;
171
+ }
172
+
173
+ const layout = (rows, width) => {
174
+ // The name column is as wide as the longest name, and at least as wide as the word total under it.
175
+ const leftWidth = Math.max(5, ...rows.map((r) => r.name.length)) + 27;
176
+ return width >= TWO_COLUMNS ? { leftWidth, columnWidth: width - leftWidth - 3, start: leftWidth + 3 } : { leftWidth, columnWidth: width - 2, start: 2 };
177
+ };
178
+
179
+ /** The number of lines of the detail pane that the scroll can hide: the content past the window of the pane. */
180
+ export function maxScroll({ rows, cursor, width, height }) {
181
+ const { columnWidth, start } = layout(rows, width);
182
+ const content = detailContent(rows[cursor], columnWidth, start, width, 0);
183
+ const body = height - 2;
184
+ const paneHeight = width >= TWO_COLUMNS ? body : body - Math.floor(body / 2) - 1;
185
+ // The pane holds the header, the content window, a blank line, and the three bottom lines.
186
+ const window = paneHeight - 5;
187
+ return window < 1 ? 0 : Math.max(0, content.length - window);
188
+ }
189
+
190
+ /**
191
+ * The lines of the overview. rows hold { rank, name, description, body, state, result, error, trust, time, tokens,
192
+ * harness, restarted }, and state is queued, running, done, or error. time is the seconds the judge has run,
193
+ * tokens the count it used (null for none), harness the one its next restart uses, restarted the seconds since
194
+ * a restart (null for none). cursor is the index of the row whose detail shows at the right; focus is 'list'
195
+ * or 'detail' (the detail pane shows an orange bar and its header turns orange); scroll hides that many lines
196
+ * of the detail content under its header. The header line holds the run: panel and target at the left, the
197
+ * version, the harness, and the elapsed time at the right edge. Two columns from TWO_COLUMNS wide, the list
198
+ * above the detail below that. height is the number of lines the overview may fill: the column divider runs
199
+ * the whole height, the rows and the detail header stay at the top, and the total row and the key and fact
200
+ * lines sit at the bottom. Without a height, or when the content is taller than the height, the lines follow
201
+ * each other and nothing scrolls.
115
202
  */
116
- export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, frame = 0 }) {
117
- const header = ` ${BOLD}panels run${RESET} ${title}${GREY} ${target} ${harness} ${clock(elapsed)}${RESET}`;
203
+ export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, height, frame = 0, version = VERSION, focus = 'list', scroll = 0 }) {
204
+ const run = ` panels run ${title} ${target}`;
205
+ const meta = `${version} · ${harness} · ${clock(elapsed)} `;
206
+ const header = ` ${BOLD}panels run${RESET} ${title}${GREY} ${target}${' '.repeat(Math.max(2, width - run.length - meta.length))}${meta}${RESET}`;
118
207
  const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
119
- const pad = Math.max(...rows.map((r) => r.name.length));
120
- const leftWidth = pad + 27;
208
+ const { leftWidth, columnWidth, start } = layout(rows, width);
209
+ const pad = leftWidth - 27;
121
210
  const cells = (score, time, tokens) => ` ${score.padStart(3)} ${time.padStart(5)} ${tokens.padStart(6)}`;
122
211
  const left = rows.map((r, i) => {
123
212
  const current = i === cursor;
@@ -127,59 +216,71 @@ export function renderOverview({ title, target, harness, elapsed, rows, cursor,
127
216
  const tokens = tokensText(r.tokens);
128
217
  if (r.state === 'running' && !r.trust) {
129
218
  const name = glimmer(r.name.padEnd(pad), frame, 6, width, current ? GLIMMER_ORANGE : GLIMMER);
130
- return `${mark}${current ? ORANGE : ''}${rank}${RESET}${name}${current ? ORANGE : GREY}${cells('', time, tokens)}${RESET}`;
219
+ return `${mark}${current ? ORANGE : ''}${rank}${RESET}${name}${current ? ORANGE : GREY}${cells('·/4', time, tokens)}${RESET}`;
131
220
  }
132
- const score = r.state === 'done' ? `${r.result.score}/4` : r.state === 'error' ? '✗' : '';
133
- const colour = current ? ORANGE : r.state === 'done' ? SCORE_COLOUR[r.result.score] : r.state === 'error' ? SCORE_COLOUR[1] : '';
221
+ // A row without a score yet holds the place of the score with ·/4.
222
+ const score = r.state === 'done' ? `${r.result.score}/4` : r.state === 'error' ? '✗' : '·/4';
223
+ const colour = current ? ORANGE : r.state === 'done' ? SCORE_COLOUR[r.result.score] : r.state === 'error' ? SCORE_COLOUR[1] : GREY;
134
224
  const name = current ? `${ORANGE}${r.name.padEnd(pad)}${RESET}` : r.state === 'queued' ? `${GREY}${r.name.padEnd(pad)}${RESET}` : r.name.padEnd(pad);
135
225
  const rest = r.state === 'running' ? `${ORANGE}press ${r.rank} and answer the trust prompt${RESET}` : `${colour}${cells(score, time, tokens)}${RESET}`;
136
226
  return `${mark}${current ? ORANGE : ''}${rank}${RESET}${name}${rest}`;
137
227
  });
138
228
  const done = rows.filter((r) => r.state === 'done');
139
- const total = cells(`${quarters(done.reduce((n, r) => n + r.result.score, 0))}/${rows.length}`, clock(Math.max(0, ...rows.map((r) => r.time ?? 0))), tokensText(rows.some((r) => r.tokens !== null) ? rows.reduce((n, r) => n + (r.tokens ?? 0), 0) : null));
140
- left.push(`${GREY}${'─'.repeat(leftWidth)}${RESET}`, `${padTo(' total', leftWidth - total.length)}${total}`);
229
+ const sum = done.length ? `${quarters(done.reduce((n, r) => n + r.result.score, 0))}/${rows.length}` : `·/${rows.length}`;
230
+ const total = cells(sum, clock(Math.max(0, ...rows.map((r) => r.time ?? 0))), tokensText(rows.some((r) => r.tokens !== null) ? rows.reduce((n, r) => n + (r.tokens ?? 0), 0) : null));
231
+ const totalLine = `${padTo(' total', leftWidth - total.length)}${done.length ? total : total.replace(sum, `${GREY}${sum}${RESET}`)}`;
232
+ const leftBottom = [`${GREY}${'─'.repeat(leftWidth)}${RESET}`, totalLine];
141
233
 
142
234
  const row = rows[cursor];
143
- const right = [];
144
- const detail = (columnWidth) => {
145
- right.length = 0;
146
- const level = row.state === 'done' ? `${row.result.score}/4 ${LEVELS[row.result.score]}` : row.state === 'error' ? 'error' : row.state;
147
- const name = `${row.rank} ${row.name.toUpperCase()}`;
148
- right.push(`${BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`, '');
149
- const text = row.state === 'done' ? row.result.verdict
150
- : row.state === 'error' ? row.error
151
- : row.state === 'queued' ? 'The judge has not started.'
152
- : row.restarted !== null ? `Restarted with ${row.harness} ${clock(row.restarted)} ago. Press ${row.rank} to watch it.`
153
- : `The judge has run for ${clock(row.time ?? 0)}. Press ${row.rank} to watch it.`;
154
- right.push(...wrap(text, columnWidth), '');
155
- right.push(`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`, '');
156
- const facts = [`judge ${row.rank}`, row.harness, ...(row.time === null ? [] : [clock(row.time)]), ...(row.state === 'done' || row.state === 'error' ? [`judges/${row.rank}/answer.json`] : [])];
157
- right.push(`${GREY}${facts.join(' · ')}${RESET}`);
235
+ const level = row.state === 'done' ? `${row.result.score}/4 ${LEVELS[row.result.score]}` : row.state === 'error' ? 'error' : row.state;
236
+ const name = `${row.rank} ${row.name.toUpperCase()}`;
237
+ const detailHeader = `${focus === 'detail' ? `${ORANGE}${BOLD}` : BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`;
238
+ const content = detailContent(row, columnWidth, start, width, frame);
239
+ const facts = [`judge ${row.rank}`, row.harness, ...(row.time === null ? [] : [clock(row.time)]), ...(row.state === 'done' || row.state === 'error' ? [`judges/${row.rank}/answer.json`] : [])];
240
+ const bottom = [`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`, '', `${GREY}${facts.join(' · ')}${RESET}`];
241
+
242
+ // top, gap blank lines, then bottom; with a height that holds them, the blank lines grow so bottom ends at the height.
243
+ const pane = (top, bottomLines, lines, gap) => {
244
+ const fill = lines === undefined || lines < top.length + gap + bottomLines.length ? gap : lines - top.length - bottomLines.length;
245
+ return [...top, ...Array(fill).fill(''), ...bottomLines];
246
+ };
247
+ // The detail pane: the header, a window of the content that the scroll moves, a blank line, then the bottom lines.
248
+ const detailPane = (lines) => {
249
+ const window = lines === undefined ? 0 : lines - 5;
250
+ if (window < 1) return pane([detailHeader, ...content], bottom, lines, 1);
251
+ const from = Math.min(scroll, Math.max(0, content.length - window));
252
+ return [detailHeader, ...content.slice(from, from + window), ...Array(Math.max(0, window - content.length)).fill(''), '', ...bottom];
158
253
  };
254
+ const edge = focus === 'detail' ? `${ORANGE}▌${RESET}` : `${GREY}│${RESET}`;
159
255
 
160
256
  const lines = [header, rule];
257
+ const body = height === undefined ? undefined : height - 2;
161
258
  if (width >= TWO_COLUMNS) {
162
- detail(width - leftWidth - 3);
163
- for (let i = 0; i < Math.max(left.length, right.length); i++) lines.push(`${padTo(left[i] ?? '', leftWidth)}${GREY} │ ${RESET}${right[i] ?? ''}`);
259
+ const leftLines = pane(left, leftBottom, body, 0);
260
+ const rightLines = detailPane(body);
261
+ for (let i = 0; i < Math.max(leftLines.length, rightLines.length); i++) lines.push(`${padTo(leftLines[i] ?? '', leftWidth)} ${edge} ${rightLines[i] ?? ''}`);
164
262
  } else {
165
- detail(width - 2);
166
- lines.push(...left, '', ...right.map((l) => ` ${l}`));
263
+ const half = body === undefined ? undefined : Math.floor(body / 2);
264
+ const list = pane(left, leftBottom, half, 0);
265
+ lines.push(...list, '', ...detailPane(body === undefined ? undefined : body - list.length - 1).map((l) => `${focus === 'detail' ? `${ORANGE}▌${RESET}` : ' '} ${l}`));
167
266
  }
168
267
  return lines.map((l) => clip(l, width));
169
268
  }
170
269
 
171
270
  /**
172
271
  * The three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
173
- * the elapsed time at the left and the keys of the focus at the right. The first tab is the overview, then the
272
+ * the elapsed time at the left and the keys of the focus at the right. The first tab is Overview, then the
174
273
  * name of each criterion, with a grey │ between the tabs. A running tab glimmers. The names step down to the
175
274
  * first word when they do not fit the width. focus is 'strip' (the active tab is bold orange, the arrows move
176
- * it) or 'judge' (the active tab is bold ink, the keys go to the judge). confirm replaces the progress with
177
- * the quit question.
275
+ * it), 'detail' (the detail pane of the overview scrolls), or 'judge' (the active tab is bold ink, the keys go to
276
+ * the judge). confirm replaces the progress with the quit question.
178
277
  */
179
278
  export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', elapsed = 0, confirm = false }) {
180
279
  const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
181
- const keys = focus === 'judge' ? 'ctrl-q tabs' : view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
182
- const names = (level) => ['overview', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
280
+ const keys = focus === 'judge' ? 'ctrl-q tabs'
281
+ : focus === 'detail' ? '↑ ↓ scroll · enter list · ← → tabs · q q quit'
282
+ : view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
283
+ const names = (level) => ['Overview', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
183
284
  const used = (list) => 1 + list.reduce((n, t) => n + t.length, 0) + 3 * (list.length - 1);
184
285
  const list = [0, 1].map(names).find((l) => used(l) <= width) ?? names(1);
185
286
  let line = ' ';
@@ -204,40 +305,52 @@ export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', ela
204
305
  }
205
306
 
206
307
  /**
207
- * The next { view, confirm, focus, cursor } after one key, plus send: true when the key goes to the judge,
308
+ * The next { view, confirm, focus, cursor, scroll } after one key, plus send: true when the key goes to the judge,
208
309
  * quit: true, restart: N to restart judge N, or flip: N to flip the harness of judge N. view is 0 for the
209
310
  * overview, or N for judge N. open holds the ranks of the judges that have started, count the number of
210
- * criteria. With the focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to
211
- * the strip. With the focus on the strip, the arrows left and right move the active tab and switch the view,
212
- * 1-9 jump to a started judge, 0 to the overview, Enter, Esc, or ctrl-q give the focus back to the judge, and
213
- * q quits (a second q confirms while a judge runs). On the overview, which takes no text, up and down move the
214
- * cursor, r restarts the judge of the cursor row, and h flips its harness.
311
+ * criteria, maxScroll the lines the detail pane can hide, page the lines one PgUp or PgDn moves. With the
312
+ * focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
313
+ * focus on the strip, the arrows left and right move the active tab and switch the view, 1-9 jump to a started
314
+ * judge, 0 to the overview, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (a second q
315
+ * confirms while a judge runs). On the overview, which takes no text, up and down move the cursor, r restarts
316
+ * the judge of the cursor row, h flips its harness, and Enter moves the focus into the detail pane: there up
317
+ * and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus to the list.
215
318
  */
216
- export function press({ view, confirm, focus, cursor }, key, { open, running, count }) {
217
- const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor });
218
- if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, send: true };
319
+ export function press({ view, confirm, focus, cursor, scroll = 0 }, key, { open, running, count, maxScroll: limit = 0, page = 10 }) {
320
+ const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor, scroll });
321
+ if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, scroll, send: true };
219
322
  if (confirm) return { ...strip(0), quit: key === 'q' };
220
- if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor } : { ...strip(0), quit: true };
323
+ if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor, scroll } : { ...strip(0), quit: true };
221
324
  if (RIGHT.includes(key) || LEFT.includes(key)) {
222
325
  const cycle = [0, ...open];
223
326
  const step = RIGHT.includes(key) ? 1 : -1;
224
327
  return strip(cycle[(cycle.indexOf(view) + step + cycle.length) % cycle.length]);
225
328
  }
226
- if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : strip(view);
329
+ if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : { ...strip(view), focus };
227
330
  if (key === '0') return strip(0);
228
331
  if (view === 0) {
229
- if (UP.includes(key)) return { ...strip(0), cursor: Math.max(0, cursor - 1) };
230
- if (DOWN.includes(key)) return { ...strip(0), cursor: Math.min(count - 1, cursor + 1) };
231
- if (key === 'r') return open.includes(cursor + 1) ? { ...strip(0), restart: cursor + 1 } : strip(0);
232
- if (key === 'h') return { ...strip(0), flip: cursor + 1 };
332
+ const detail = (s) => ({ view: 0, confirm: false, focus: 'detail', cursor, scroll: Math.max(0, Math.min(limit, s)) });
333
+ if (key === 'r') return open.includes(cursor + 1) ? { ...strip(0), focus, restart: cursor + 1 } : { ...strip(0), focus };
334
+ if (key === 'h') return { ...strip(0), focus, flip: cursor + 1 };
335
+ if (focus === 'detail') {
336
+ if (UP.includes(key)) return detail(scroll - 1);
337
+ if (DOWN.includes(key)) return detail(scroll + 1);
338
+ if (key === '\x1b[5~') return detail(scroll - page);
339
+ if (key === '\x1b[6~') return detail(scroll + page);
340
+ if (key === '\r' || key === '\x1b') return strip(0);
341
+ return detail(scroll);
342
+ }
343
+ if (UP.includes(key)) return { ...strip(0), cursor: Math.max(0, cursor - 1), scroll: 0 };
344
+ if (DOWN.includes(key)) return { ...strip(0), cursor: Math.min(count - 1, cursor + 1), scroll: 0 };
345
+ if (key === '\r') return detail(0);
233
346
  return strip(0);
234
347
  }
235
- if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor };
348
+ if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor, scroll };
236
349
  return strip(view);
237
350
  }
238
351
 
239
352
  /** Runs the judges, shows the overview, and writes the report. Resolves when the engineer quits. */
240
- export function runTable({ panel, runDir, harness, cwd, target, brief, sources }) {
353
+ export function runTable({ panel, runDir, harness, cwd, target, brief, sources, yolo = false }) {
241
354
  const out = process.stdout;
242
355
  const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
243
356
  const judges = [];
@@ -245,7 +358,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
245
358
  const harnesses = criteria.map(() => harness);
246
359
  let rows = [];
247
360
  let final = null;
248
- let ui = { view: 0, confirm: false, focus: 'strip', cursor: 0 };
361
+ let ui = { view: 0, confirm: false, focus: 'strip', cursor: 0, scroll: 0 };
249
362
  let stripTimer;
250
363
  let lastData = 0;
251
364
  const started = Date.now();
@@ -282,7 +395,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
282
395
  }
283
396
 
284
397
  function start(rank, restartedAt = null) {
285
- const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target, brief, sources });
398
+ const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target, brief, sources, yolo });
286
399
  const { cols, rows: lines } = size();
287
400
  const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
288
401
  const serializer = new SerializeAddon();
@@ -362,7 +475,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
362
475
 
363
476
  let frame = 0;
364
477
  function draw() {
365
- const lines = renderOverview({ title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, frame });
478
+ const lines = renderOverview({ title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, height: out.rows - STRIP_ROWS, frame, focus: ui.focus === 'detail' ? 'detail' : 'list', scroll: ui.scroll });
366
479
  while (lines.length < out.rows - STRIP_ROWS) lines.push('');
367
480
  out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
368
481
  drawStrip();
@@ -396,8 +509,10 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
396
509
  process.stdin.on('data', (data) => {
397
510
  const key = data.toString();
398
511
  const from = ui.view;
399
- const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running'), count: criteria.length });
400
- ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor };
512
+ const height = out.rows - STRIP_ROWS;
513
+ const limit = rows.length ? maxScroll({ rows, cursor: ui.cursor, width: out.columns, height }) : 0;
514
+ const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running'), count: criteria.length, maxScroll: limit, page: Math.max(1, height - 7) });
515
+ ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor, scroll: next.scroll };
401
516
  if (next.send) {
402
517
  judges[from - 1].proc.write(key);
403
518
  } else if (next.quit) {
package/package.json CHANGED
@@ -1,17 +1,30 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
6
  "type": "module",
7
- "bin": { "panels": "bin/panels.js" },
8
- "files": ["bin", "lib", "prompts", "schema", "README.md"],
7
+ "bin": {
8
+ "panels": "bin/panels.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "prompts",
14
+ "schema",
15
+ "README.md"
16
+ ],
9
17
  "license": "UNLICENSED",
10
- "engines": { "node": ">=20" },
11
- "scripts": { "test": "node --test test/*.test.js" },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test test/*.test.js"
23
+ },
12
24
  "dependencies": {
13
25
  "@homebridge/node-pty-prebuilt-multiarch": "0.14.1",
14
26
  "@xterm/addon-serialize": "0.14.0",
15
- "@xterm/headless": "6.0.0"
27
+ "@xterm/headless": "6.0.0",
28
+ "figlet": "1.12.0"
16
29
  }
17
30
  }
@@ -33,7 +33,7 @@ EVIDENCE
33
33
 
34
34
  THE VERDICT
35
35
 
36
- One paragraph of 600 characters at most: what holds, then what is wrong and where, each place in brackets, then "Not checked:" and the tests you could not do, when there are any. Judge; do not propose fixes. The fixes are the job of the engineer.
36
+ One paragraph: what holds, then what is wrong and where, each place in brackets, then "Not checked:" and the tests you could not do, when there are any. Judge; do not propose fixes. The fixes are the job of the engineer.
37
37
 
38
38
  PROHIBITIONS
39
39
 
@@ -51,7 +51,7 @@ Return one JSON object and nothing else:
51
51
  {
52
52
  "name": string, the name of your criterion,
53
53
  "score": integer 1 to 4,
54
- "verdict": string, one paragraph, 600 characters at most
54
+ "verdict": string, one paragraph
55
55
  }
56
56
 
57
57
  Example:
@@ -31,7 +31,7 @@
31
31
  "properties": {
32
32
  "name": { "type": "string" },
33
33
  "score": { "type": "integer", "minimum": 1, "maximum": 4 },
34
- "verdict": { "type": "string", "minLength": 1, "maxLength": 600, "description": "One paragraph: what holds, what is wrong and where (the place in brackets), and \"Not checked: ...\" at the end when something was not checked." }
34
+ "verdict": { "type": "string", "minLength": 1, "description": "One paragraph: what holds, what is wrong and where (the place in brackets), and \"Not checked: ...\" at the end when something was not checked." }
35
35
  }
36
36
  }
37
37
  }