@xsft/panels 0.7.1 → 0.8.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 +10 -10
- package/bin/panels.js +2 -5
- package/lib/args.js +3 -9
- package/lib/judge.js +17 -24
- package/lib/panel.js +6 -2
- package/lib/report.js +6 -2
- package/lib/table.js +201 -130
- package/package.json +2 -3
- package/prompts/judge-system-prompt.txt +21 -23
package/README.md
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
```text
|
|
6
6
|
panels run <panel> [target...] [flags]
|
|
7
7
|
|
|
8
|
-
<panel> owner/name, or owner/name
|
|
8
|
+
<panel> owner/name for the latest version, or owner/name@N to pin version N
|
|
9
9
|
[target...] free words, joined with single spaces into one target
|
|
10
10
|
|
|
11
11
|
--harness <name> claude | codex default: claude if installed, else codex
|
|
12
|
-
--brief <file> the brief default: none
|
|
13
|
-
--sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
|
|
14
12
|
--yolo the judges skip every permission question (claude --dangerously-skip-permissions, codex --yolo).
|
|
15
13
|
Without it, a judge asks its own questions in its tab. Without a terminal, the flag is required.
|
|
16
14
|
-- end of the flags; each later word joins the target
|
|
@@ -20,22 +18,24 @@ exit 1 fail: every judge answered, and one or more criteria scored below 4
|
|
|
20
18
|
exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed
|
|
21
19
|
```
|
|
22
20
|
|
|
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
|
|
21
|
+
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 of the target, no forms, no posts) is the only guard. Do not run a panel on a repository that you do not trust.
|
|
24
22
|
|
|
25
23
|
```sh
|
|
26
|
-
npx @xsft/panels run xsft/marketing-site https://example.com --
|
|
27
|
-
npx @xsft/panels run xsft/marketing-site
|
|
24
|
+
npx @xsft/panels run xsft/marketing-site https://example.com --harness claude --yolo
|
|
25
|
+
npx @xsft/panels run xsft/marketing-site@8 . at http://localhost:4321 --harness codex
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes.
|
|
28
|
+
Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes. The criteria of a panel say what to assess; the system prompt of the CLI (`prompts/judge-system-prompt.txt`) carries how to judge and the four levels: 4 Met, 3 Almost met, 2 Falls short, 1 Fails. Only 4 passes.
|
|
31
29
|
|
|
32
30
|
## Terminal mode
|
|
33
31
|
|
|
34
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. Plain mode needs `--yolo`, because a background judge cannot answer a question; without it the command exits 2.
|
|
35
33
|
|
|
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,
|
|
34
|
+
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, and at the bottom the key line and a grey fact line with the judge number, the harness, the time, and the answer file. While a judge runs, its detail shows the Cross mark, a half disc in a square frame drawn in a halftone screen that breathes, centred in the pane, with the time under it. One soft wave sweeps across the running rows and the mark. 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
35
|
|
|
38
|
-
|
|
36
|
+
The second tab, `Panel`, holds the text of the panel: the criteria at the left with the same cursor as the overview, and at the right the description and the body of the cursor criterion as plain text. `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.
|
|
37
|
+
|
|
38
|
+
A tab strip on the last three lines shows a hairline, the tabs (`Overview`, `Panel`, 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.
|
|
39
39
|
|
|
40
40
|
## Run folder
|
|
41
41
|
|
|
@@ -50,7 +50,7 @@ The last line on stdout is the path of `report.json`.
|
|
|
50
50
|
## Skipped
|
|
51
51
|
|
|
52
52
|
- Run upload to cross.software, and `--no-upload`.
|
|
53
|
-
- `--version`: `owner/name
|
|
53
|
+
- `--version`: `owner/name@N` pins a version. The CLI fetches the panel from the api on every run and never caches it.
|
|
54
54
|
- `--out`: the run folder is fixed.
|
|
55
55
|
- `--json`: stdout ends with the path of `report.json`.
|
|
56
56
|
- `--concurrency`: fixed at 4.
|
package/bin/panels.js
CHANGED
|
@@ -27,12 +27,11 @@ async function pool(items, size, fn) {
|
|
|
27
27
|
|
|
28
28
|
const EXIT = { pass: 0, fail: 1, error: 2 };
|
|
29
29
|
|
|
30
|
-
/** Runs the judges in the
|
|
30
|
+
/** Runs the judges in the overview, and exits from report.json when the engineer quits. Prints nothing. */
|
|
31
31
|
async function runOnTerminal({ panel, runDir, harness, cwd, args }) {
|
|
32
|
-
await runTable({ panel, runDir, harness, cwd, target: args.target,
|
|
32
|
+
await runTable({ panel, runDir, harness, cwd, target: args.target, yolo: args.yolo });
|
|
33
33
|
const reportPath = join(runDir, 'report.json');
|
|
34
34
|
if (!existsSync(reportPath)) return 2;
|
|
35
|
-
console.log(reportPath);
|
|
36
35
|
return EXIT[JSON.parse(readFileSync(reportPath, 'utf8')).status];
|
|
37
36
|
}
|
|
38
37
|
|
|
@@ -78,8 +77,6 @@ async function main() {
|
|
|
78
77
|
harness,
|
|
79
78
|
cwd,
|
|
80
79
|
target: args.target,
|
|
81
|
-
brief: args.brief,
|
|
82
|
-
sources: args.sources,
|
|
83
80
|
yolo: true,
|
|
84
81
|
}));
|
|
85
82
|
const report = buildReport({ panel, target: args.target, outcomes });
|
package/lib/args.js
CHANGED
|
@@ -2,12 +2,10 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
|
|
3
3
|
export const SYNOPSIS = `panels run <panel> [target...] [flags]
|
|
4
4
|
|
|
5
|
-
<panel> owner/name, or owner/name
|
|
5
|
+
<panel> owner/name for the latest version, or owner/name@N to pin version N
|
|
6
6
|
[target...] free words, joined with single spaces into one target
|
|
7
7
|
|
|
8
8
|
--harness <name> claude | codex default: claude if installed, else codex
|
|
9
|
-
--brief <file> the brief default: none
|
|
10
|
-
--sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
|
|
11
9
|
--yolo the judges skip every permission question (claude --dangerously-skip-permissions, codex --yolo).
|
|
12
10
|
Without it, a judge asks its own questions in its tab. Without a terminal, the flag is required.
|
|
13
11
|
-- end of the flags; each later word joins the target
|
|
@@ -18,13 +16,11 @@ exit 2 error: bad flags, no target, panel not found, harness missing, or a judg
|
|
|
18
16
|
|
|
19
17
|
const options = {
|
|
20
18
|
harness: { type: 'string' },
|
|
21
|
-
brief: { type: 'string' },
|
|
22
|
-
sources: { type: 'string', multiple: true },
|
|
23
19
|
yolo: { type: 'boolean' },
|
|
24
20
|
help: { type: 'boolean' },
|
|
25
21
|
};
|
|
26
22
|
|
|
27
|
-
const PANEL = /^([a-z0-9-]+)\/([a-z0-9-]+)(
|
|
23
|
+
const PANEL = /^([a-z0-9-]+)\/([a-z0-9-]+)(?:@([1-9]\d*))?$/;
|
|
28
24
|
|
|
29
25
|
/** Returns the run options, { help: true }, { usage: true }, or { error } with a one-line message. */
|
|
30
26
|
export function parse(argv) {
|
|
@@ -38,15 +34,13 @@ export function parse(argv) {
|
|
|
38
34
|
const [command, panel, ...words] = positionals;
|
|
39
35
|
if (command !== 'run' || panel === undefined) return { usage: true };
|
|
40
36
|
const match = PANEL.exec(panel);
|
|
41
|
-
if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name
|
|
37
|
+
if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name@N` };
|
|
42
38
|
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' };
|
|
43
39
|
if (values.harness !== undefined && !['claude', 'codex'].includes(values.harness)) return { error: `--harness needs claude or codex, not ${values.harness}` };
|
|
44
40
|
return {
|
|
45
41
|
panel: { owner: match[1], name: match[2], version: match[3] && Number(match[3]) },
|
|
46
42
|
target: words.join(' '),
|
|
47
43
|
harness: values.harness,
|
|
48
|
-
brief: values.brief,
|
|
49
|
-
sources: values.sources ?? [],
|
|
50
44
|
yolo: Boolean(values.yolo),
|
|
51
45
|
};
|
|
52
46
|
}
|
package/lib/judge.js
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { join } from 'node:path';
|
|
4
4
|
|
|
5
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
8
|
// A judge gets no tool list, no permission mode, and no sandbox flag: the harness defaults, and its own questions
|
|
9
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,
|
|
10
|
+
// prompt holds the rules on writing files, forms, and messages. exec resume takes no sandbox flag,
|
|
11
11
|
// so it gets the same setting as config values.
|
|
12
12
|
// codex --yolo is the alias of --dangerously-bypass-approvals-and-sandbox.
|
|
13
13
|
const yoloFlags = (yolo, harness) => (!yolo ? [] : harness === 'claude' ? ['--dangerously-skip-permissions'] : ['--yolo']);
|
|
14
14
|
const CODEX_RESUME_YOLO = ['-c', 'sandbox_mode="danger-full-access"', '-c', 'approval_policy="never"'];
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* The user message of a judge. criterion is { rank, name, description, body }.
|
|
18
|
-
* because a body is markdown with headings of its own.
|
|
17
|
+
* The user message of a judge: the criterion and the target. criterion is { rank, name, description, body }.
|
|
18
|
+
* Each part sits in a tag, because a body is markdown with headings of its own.
|
|
19
19
|
*/
|
|
20
|
-
export function userMessage(criterion, { target
|
|
21
|
-
|
|
22
|
-
parts.push(`<target>\n${target}\n</target>`, `<brief>\n${brief ?? 'none supplied'}\n</brief>`, `<sources>\n${sources.length ? sources.join('\n') : 'none'}\n</sources>`);
|
|
23
|
-
return `${parts.join('\n\n')}\n`;
|
|
20
|
+
export function userMessage(criterion, { target }) {
|
|
21
|
+
return `<criterion rank="${criterion.rank}" name="${criterion.name}">\nDescription: ${criterion.description}\n\n${criterion.body}\n</criterion>\n\n<target>\n${target}\n</target>\n`;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
/** The output schema of a judge: one criterion object { name, score, verdict }, with the name fixed. */
|
|
@@ -41,12 +39,6 @@ export function check(answer, criterion) {
|
|
|
41
39
|
return errors;
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
/** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
|
|
45
|
-
export function addDirs(cwd, brief, sources) {
|
|
46
|
-
const paths = [brief && dirname(resolve(cwd, brief)), ...sources.filter((s) => !/^https?:\/\//.test(s)).map((s) => resolve(cwd, s))];
|
|
47
|
-
return [...new Set(paths.filter(Boolean))].filter((p) => relative(cwd, p).split(sep)[0] === '..');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
42
|
function exec(command, args, { cwd, stdin, timeout }) {
|
|
51
43
|
return new Promise((done) => {
|
|
52
44
|
const child = spawn(command, args, { cwd, timeout, stdio: [stdin, 'pipe', 'pipe'] });
|
|
@@ -65,7 +57,6 @@ function exec(command, args, { cwd, stdin, timeout }) {
|
|
|
65
57
|
async function askClaude(j, prompt, session) {
|
|
66
58
|
const args = [
|
|
67
59
|
'-p', ...yoloFlags(j.yolo, 'claude'),
|
|
68
|
-
...j.addDirs.flatMap((d) => ['--add-dir', d]),
|
|
69
60
|
'--system-prompt', SYSTEM_PROMPT,
|
|
70
61
|
'--json-schema', JSON.stringify(j.schema),
|
|
71
62
|
'--output-format', 'json',
|
|
@@ -109,13 +100,13 @@ export function parseAnswer(text) {
|
|
|
109
100
|
* Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
|
|
110
101
|
* Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
|
|
111
102
|
*/
|
|
112
|
-
export async function runJudge({ dir, criterion, harness, cwd, target,
|
|
103
|
+
export async function runJudge({ dir, criterion, harness, cwd, target, yolo = false }, command = harness) {
|
|
113
104
|
mkdirSync(dir, { recursive: true });
|
|
114
|
-
const prompt = userMessage(criterion, { target
|
|
105
|
+
const prompt = userMessage(criterion, { target });
|
|
115
106
|
const schema = judgeSchema(criterion);
|
|
116
107
|
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
117
108
|
writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
|
|
118
|
-
const j = { dir, cwd, schema, command, yolo,
|
|
109
|
+
const j = { dir, cwd, schema, command, yolo, timeout: MINUTES_PER_CRITERION * 60_000 };
|
|
119
110
|
const ask = harness === 'claude' ? askClaude : askCodex;
|
|
120
111
|
let reply = await ask(j, prompt);
|
|
121
112
|
if (reply.error) return reply;
|
|
@@ -137,17 +128,19 @@ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOM
|
|
|
137
128
|
* Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
|
|
138
129
|
* arguments that start the judge in its interactive harness.
|
|
139
130
|
*/
|
|
140
|
-
export function writeJudge({ dir, criterion, harness, cwd, target,
|
|
131
|
+
export function writeJudge({ dir, criterion, harness, cwd, target, yolo = false }) {
|
|
141
132
|
mkdirSync(dir, { recursive: true });
|
|
142
|
-
|
|
143
|
-
|
|
133
|
+
// The answer file rule sits at the end of the user prompt and in the system prompt, so a judge that hands the
|
|
134
|
+
// work to a sub-agent still leaves the file at the exact path.
|
|
135
|
+
const closing = `The engineer ran the command in ${cwd}. Your answer counts only as the file ${join(dir, 'answer.json')}: write it with that exact absolute path, as one JSON object { name, score, verdict } that matches ${join(dir, 'schema.json')}. JSON in your reply does not count. Whoever does the work, that file must exist at that path when you stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
|
|
136
|
+
const prompt = `${userMessage(criterion, { target })}\n${closing}`;
|
|
137
|
+
const system = `${SYSTEM_PROMPT}\n${closing}`;
|
|
144
138
|
writeFileSync(join(dir, 'prompt.md'), prompt);
|
|
145
139
|
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criterion), null, 2));
|
|
146
140
|
if (harness === 'codex') {
|
|
147
141
|
// Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
|
|
148
142
|
// write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
|
|
149
|
-
return ['codex', '-C', dir, ...yoloFlags(yolo, 'codex'), '-c', `developer_instructions=${tomlString(
|
|
143
|
+
return ['codex', '-C', dir, ...yoloFlags(yolo, 'codex'), '-c', `developer_instructions=${tomlString(system)}`, prompt];
|
|
150
144
|
}
|
|
151
|
-
|
|
152
|
-
return ['claude', ...yoloFlags(yolo, 'claude'), '--system-prompt', SYSTEM_PROMPT, ...add, prompt];
|
|
145
|
+
return ['claude', ...yoloFlags(yolo, 'claude'), '--system-prompt', system, prompt];
|
|
153
146
|
}
|
package/lib/panel.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
const API = 'https://cross.software/panels/api/panels';
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Reads a panel from the public api on every run, the latest version unless one is pinned, and never from a
|
|
5
|
+
* cache: the api answers no-store, and the fetch asks for no store too, so a proxy cannot keep a copy.
|
|
6
|
+
* An unpublished panel also answers 404.
|
|
7
|
+
*/
|
|
4
8
|
export async function fetchPanel({ owner, name, version }) {
|
|
5
|
-
const res = await fetch(`${API}/${owner}/${name}${version ? `/v/${version}` : ''}
|
|
9
|
+
const res = await fetch(`${API}/${owner}/${name}${version ? `/v/${version}` : ''}`, { cache: 'no-store' });
|
|
6
10
|
if (res.status === 404) return { error: 'panel not found' };
|
|
7
11
|
if (!res.ok) return { error: `panel fetch failed: ${res.status}` };
|
|
8
12
|
return { panel: await res.json() };
|
package/lib/report.js
CHANGED
|
@@ -18,7 +18,11 @@ export function buildReport({ panel, target, outcomes }) {
|
|
|
18
18
|
return { panel: `${panel.owner}/${panel.name}@${panel.version}`, target, status, criteria };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The lines that the plain mode prints: the run (panel and its version, the target, the CLI version), then
|
|
23
|
+
* "N/4 name" and the verdict under it, for each criterion.
|
|
24
|
+
*/
|
|
22
25
|
export function renderPlain(report) {
|
|
23
|
-
|
|
26
|
+
const [name, version] = report.panel.split('@');
|
|
27
|
+
return [`panels run ${name} v${version} ${report.target} ${VERSION}`, '', ...report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, ''])];
|
|
24
28
|
}
|
package/lib/table.js
CHANGED
|
@@ -5,7 +5,6 @@ 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';
|
|
9
8
|
import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
|
|
10
9
|
import { buildReport, VERSION } from './report.js';
|
|
11
10
|
|
|
@@ -76,18 +75,9 @@ export function tokensOnScreen(text) {
|
|
|
76
75
|
return best;
|
|
77
76
|
}
|
|
78
77
|
|
|
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
|
-
|
|
89
78
|
const plainLength = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
90
79
|
const padTo = (s, width) => s + ' '.repeat(Math.max(0, width - plainLength(s)));
|
|
80
|
+
const centre = (s, width) => ' '.repeat(Math.max(0, Math.floor((width - plainLength(s)) / 2))) + s;
|
|
91
81
|
|
|
92
82
|
/** The first width visible characters of a styled line, with every escape sequence kept, so the line never wraps. */
|
|
93
83
|
export function clip(line, width) {
|
|
@@ -117,8 +107,41 @@ export function wrap(text, width) {
|
|
|
117
107
|
/** The scores added as quarters: 1/4 + 1/4 + 2/4 is "1". No trailing zeros. */
|
|
118
108
|
const quarters = (sum) => String(Math.round((sum / 4) * 100) / 100);
|
|
119
109
|
|
|
110
|
+
// The Cross mark: a half disc inside a square frame, drawn in a halftone screen. Two sizes, rows by columns.
|
|
111
|
+
const MARK_SIZES = [[11, 22], [7, 14]];
|
|
112
|
+
const HALFTONE = [' ', '░', '▒', '▓'];
|
|
113
|
+
const BREATH = 20;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The lines of the Cross mark at frame: a square frame of box-drawing lines, grey, and inside it a half disc on
|
|
117
|
+
* the left, its flat side on the frame, made of halftone cells two columns per row. The screen breathes: its
|
|
118
|
+
* density pulses over BREATH frames, and the glimmer wave runs across the cells by screen column, where start is
|
|
119
|
+
* the screen column of the frame. size is [rows, columns].
|
|
120
|
+
*/
|
|
121
|
+
export function renderMark({ frame = 0, start = 0, width = 80, size = MARK_SIZES[0] }) {
|
|
122
|
+
const [rows, cols] = size;
|
|
123
|
+
const inner = { rows: rows - 2, cols: cols - 2 };
|
|
124
|
+
const cy = (inner.rows - 1) / 2;
|
|
125
|
+
const radius = inner.rows / 2;
|
|
126
|
+
const pulse = Math.sin((2 * Math.PI * frame) / BREATH);
|
|
127
|
+
const lines = [`${GREY}┌${'─'.repeat(inner.cols)}┐${RESET}`];
|
|
128
|
+
for (let y = 0; y < inner.rows; y++) {
|
|
129
|
+
let cells = '';
|
|
130
|
+
for (let x = 0; x < inner.cols; x++) {
|
|
131
|
+
const d = Math.hypot((x + 0.5) / 2, y - cy) / radius;
|
|
132
|
+
cells += d > 1 ? ' ' : HALFTONE[Math.max(1, Math.min(3, Math.round(1.5 + (1 - d) * 1.5 + pulse)))];
|
|
133
|
+
}
|
|
134
|
+
lines.push(`${GREY}│${RESET}${glimmer(cells, frame, start + 1, width)}${GREY}│${RESET}`);
|
|
135
|
+
}
|
|
136
|
+
lines.push(`${GREY}└${'─'.repeat(inner.cols)}┘${RESET}`);
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The size of the mark that fits a pane of columnWidth by lines, or null when only the one-line form fits. */
|
|
141
|
+
export const markSize = (columnWidth, lines) => MARK_SIZES.find(([r, c]) => r + 2 <= lines && c <= columnWidth) ?? null;
|
|
142
|
+
|
|
120
143
|
/**
|
|
121
|
-
* The body of a criterion as plain lines for the
|
|
144
|
+
* The body of a criterion as plain lines for the text pane: a heading becomes a grey upper-case label, a list
|
|
122
145
|
* item keeps its dash, a fenced block is indented four spaces as it is, a table line stays as it is, and the
|
|
123
146
|
* other text is wrapped to width with the emphasis, code, and link marks removed.
|
|
124
147
|
*/
|
|
@@ -149,23 +172,10 @@ export function plainMarkdown(markdown, width) {
|
|
|
149
172
|
return lines;
|
|
150
173
|
}
|
|
151
174
|
|
|
152
|
-
/** The lines of the
|
|
153
|
-
function
|
|
154
|
-
const lines = [
|
|
155
|
-
if (row.
|
|
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));
|
|
175
|
+
/** The lines of the body of a criterion in the Panel tab. */
|
|
176
|
+
function criterionText(row, columnWidth) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
if (row.description) lines.push(...wrap(row.description, columnWidth));
|
|
169
179
|
if (row.body) lines.push('', ...plainMarkdown(row.body, columnWidth));
|
|
170
180
|
return lines;
|
|
171
181
|
}
|
|
@@ -176,35 +186,58 @@ const layout = (rows, width) => {
|
|
|
176
186
|
return width >= TWO_COLUMNS ? { leftWidth, columnWidth: width - leftWidth - 3, start: leftWidth + 3 } : { leftWidth, columnWidth: width - 2, start: 2 };
|
|
177
187
|
};
|
|
178
188
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
189
|
+
const headerLine = (title, target, harness, elapsed, width, version) => {
|
|
190
|
+
const run = ` panels run ${title} ${target}`;
|
|
191
|
+
const meta = `${version} · ${harness} · ${clock(elapsed)} `;
|
|
192
|
+
return ` ${BOLD}panels run${RESET} ${title}${GREY} ${target}${' '.repeat(Math.max(2, width - run.length - meta.length))}${meta}${RESET}`;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const paneHeight = (width, body) => (width >= TWO_COLUMNS ? body : body - Math.floor(body / 2) - 1);
|
|
196
|
+
|
|
197
|
+
/** The number of lines of the text pane of the Panel tab that the scroll can hide. */
|
|
198
|
+
export function panelScrollMax({ rows, cursor, width, height }) {
|
|
199
|
+
const { columnWidth } = layout(rows, width);
|
|
200
|
+
const window = paneHeight(width, height - 2) - 2;
|
|
201
|
+
return window < 1 ? 0 : Math.max(0, criterionText(rows[cursor], columnWidth).length - window);
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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.
|
|
205
|
+
* Lays a page out: the header and the hairline, then the list at the left and a pane at the right from
|
|
206
|
+
* TWO_COLUMNS wide, or the list above the pane below that. pane(lines) gives the lines of the pane for a height,
|
|
207
|
+
* or for undefined when there is no height. edge is the divider character between the columns.
|
|
202
208
|
*/
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
209
|
+
function page({ header, width, height, left, leftBottom, pane, edge }) {
|
|
210
|
+
const { leftWidth } = layout(left.rows, width);
|
|
211
|
+
const fill = (top, bottom, lines) => {
|
|
212
|
+
const n = lines === undefined || lines < top.length + bottom.length ? 0 : lines - top.length - bottom.length;
|
|
213
|
+
return [...top, ...Array(n).fill(''), ...bottom];
|
|
214
|
+
};
|
|
215
|
+
const lines = [header, `${GREY}${'─'.repeat(width)}${RESET}`];
|
|
216
|
+
const body = height === undefined ? undefined : height - 2;
|
|
217
|
+
if (width >= TWO_COLUMNS) {
|
|
218
|
+
const leftLines = fill(left.lines, leftBottom, body);
|
|
219
|
+
const rightLines = pane(body);
|
|
220
|
+
for (let i = 0; i < Math.max(leftLines.length, rightLines.length); i++) lines.push(`${padTo(leftLines[i] ?? '', leftWidth)} ${edge} ${rightLines[i] ?? ''}`);
|
|
221
|
+
} else {
|
|
222
|
+
const half = body === undefined ? undefined : Math.floor(body / 2);
|
|
223
|
+
const list = fill(left.lines, leftBottom, half);
|
|
224
|
+
lines.push(...list, '', ...pane(body === undefined ? undefined : body - list.length - 1).map((l) => `${edge === `${GREY}│${RESET}` ? ' ' : edge} ${l}`));
|
|
225
|
+
}
|
|
226
|
+
return lines.map((l) => clip(l, width));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The lines of the overview. rows hold { rank, name, state, result, error, trust, time, tokens, harness, restarted },
|
|
231
|
+
* and state is queued, running, done, or error. time is the seconds the judge has run, tokens the count it used
|
|
232
|
+
* (null for none), harness the one its next restart uses, restarted the seconds since a restart (null for none).
|
|
233
|
+
* cursor is the index of the row whose detail shows at the right. The header line holds the run: panel and target
|
|
234
|
+
* at the left, the version, the harness, and the elapsed time at the right edge. Two columns from TWO_COLUMNS wide,
|
|
235
|
+
* the list above the detail below that. height is the number of lines the overview may fill: the column divider
|
|
236
|
+
* runs the whole height, the rows and the detail header stay at the top, and the total row and the key and fact
|
|
237
|
+
* lines sit at the bottom. The detail of a running judge is the Cross mark, centred in the pane, with the time
|
|
238
|
+
* under it. Without a height, or when the content is taller than the height, the lines follow each other.
|
|
239
|
+
*/
|
|
240
|
+
export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, height, frame = 0, version = VERSION }) {
|
|
208
241
|
const { leftWidth, columnWidth, start } = layout(rows, width);
|
|
209
242
|
const pad = leftWidth - 27;
|
|
210
243
|
const cells = (score, time, tokens) => ` ${score.padStart(3)} ${time.padStart(5)} ${tokens.padStart(6)}`;
|
|
@@ -229,73 +262,109 @@ export function renderOverview({ title, target, harness, elapsed, rows, cursor,
|
|
|
229
262
|
const sum = done.length ? `${quarters(done.reduce((n, r) => n + r.result.score, 0))}/${rows.length}` : `·/${rows.length}`;
|
|
230
263
|
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
264
|
const totalLine = `${padTo(' total', leftWidth - total.length)}${done.length ? total : total.replace(sum, `${GREY}${sum}${RESET}`)}`;
|
|
232
|
-
const leftBottom = [`${GREY}${'─'.repeat(leftWidth)}${RESET}`, totalLine];
|
|
233
265
|
|
|
234
266
|
const row = rows[cursor];
|
|
235
267
|
const level = row.state === 'done' ? `${row.result.score}/4 ${LEVELS[row.result.score]}` : row.state === 'error' ? 'error' : row.state;
|
|
236
268
|
const name = `${row.rank} ${row.name.toUpperCase()}`;
|
|
237
|
-
const detailHeader = `${
|
|
238
|
-
const content = detailContent(row, columnWidth, start, width, frame);
|
|
269
|
+
const detailHeader = `${BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`;
|
|
239
270
|
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
271
|
const bottom = [`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`, '', `${GREY}${facts.join(' · ')}${RESET}`];
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
272
|
+
// The pane: the header, the content, then the bottom lines at the bottom. A running judge shows the mark centred.
|
|
273
|
+
const pane = (lines) => {
|
|
274
|
+
// The pane holds the header, a blank line, the content, a blank line, then the bottom lines.
|
|
275
|
+
const window = lines === undefined ? undefined : lines - 2 - 1 - bottom.length;
|
|
276
|
+
let content;
|
|
277
|
+
if (row.state === 'running' && !row.trust) {
|
|
278
|
+
const since = row.restarted !== null ? `restarted with ${row.harness} · ` : '';
|
|
279
|
+
const line = `${GREY}${since}${clock(row.time ?? 0)} · ${row.rank} to watch${RESET}`;
|
|
280
|
+
const size = markSize(columnWidth, window ?? MARK_SIZES[0][0] + 2);
|
|
281
|
+
const art = size ? renderMark({ frame, start: start + Math.floor((columnWidth - size[1]) / 2), width, size }) : [glimmer('judging', frame, start + Math.floor((columnWidth - 7) / 2), width)];
|
|
282
|
+
const block = [...art, '', line].map((l) => centre(l, columnWidth));
|
|
283
|
+
const above = window === undefined ? 0 : Math.max(0, Math.floor((window - block.length) / 2));
|
|
284
|
+
content = [...Array(above).fill(''), ...block];
|
|
285
|
+
} else {
|
|
286
|
+
const text = row.state === 'done' ? row.result.verdict : row.state === 'error' ? row.error : row.state === 'queued' ? 'The judge has not started.' : `Press ${row.rank} and answer the trust prompt.`;
|
|
287
|
+
content = wrap(text, columnWidth);
|
|
288
|
+
}
|
|
289
|
+
const top = [detailHeader, '', ...content];
|
|
290
|
+
if (window === undefined || content.length > window) return [...top, '', ...bottom];
|
|
291
|
+
return [...top, ...Array(window - content.length).fill(''), '', ...bottom];
|
|
253
292
|
};
|
|
254
|
-
|
|
293
|
+
return page({
|
|
294
|
+
header: headerLine(title, target, harness, elapsed, width, version),
|
|
295
|
+
width,
|
|
296
|
+
height,
|
|
297
|
+
left: { rows, lines: left },
|
|
298
|
+
leftBottom: [`${GREY}${'─'.repeat(leftWidth)}${RESET}`, totalLine],
|
|
299
|
+
pane,
|
|
300
|
+
edge: `${GREY}│${RESET}`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
255
303
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
304
|
+
/**
|
|
305
|
+
* The lines of the Panel tab: the same header, the criteria at the left (rank and name, the cursor row orange),
|
|
306
|
+
* and at the right the description and the body of the cursor criterion as plain text. focus 'text' marks the
|
|
307
|
+
* pane with an orange edge and an orange header, and scroll hides that many lines of the text.
|
|
308
|
+
*/
|
|
309
|
+
export function renderPanel({ title, target, harness, elapsed, rows, cursor, width, height, version = VERSION, focus = 'list', scroll = 0 }) {
|
|
310
|
+
const { leftWidth, columnWidth } = layout(rows, width);
|
|
311
|
+
const pad = leftWidth - 27;
|
|
312
|
+
const left = rows.map((r, i) => (i === cursor ? `${ORANGE}▸ ${String(r.rank).padStart(2)} ${r.name.padEnd(pad)}${RESET}` : ` ${String(r.rank).padStart(2)} ${r.name.padEnd(pad)}`));
|
|
313
|
+
const row = rows[cursor];
|
|
314
|
+
const name = `${row.rank} ${row.name.toUpperCase()}`;
|
|
315
|
+
const header = `${focus === 'text' ? `${ORANGE}${BOLD}` : BOLD}${name}${RESET}`;
|
|
316
|
+
const text = criterionText(row, columnWidth);
|
|
317
|
+
const pane = (lines) => {
|
|
318
|
+
const window = lines === undefined ? text.length : lines - 2;
|
|
319
|
+
const from = Math.min(scroll, Math.max(0, text.length - window));
|
|
320
|
+
return [header, '', ...text.slice(from, from + Math.max(0, window))];
|
|
321
|
+
};
|
|
322
|
+
return page({
|
|
323
|
+
header: headerLine(title, target, harness, elapsed, width, version),
|
|
324
|
+
width,
|
|
325
|
+
height,
|
|
326
|
+
left: { rows, lines: left },
|
|
327
|
+
leftBottom: [],
|
|
328
|
+
pane,
|
|
329
|
+
edge: focus === 'text' ? `${ORANGE}▌${RESET}` : `${GREY}│${RESET}`,
|
|
330
|
+
});
|
|
268
331
|
}
|
|
269
332
|
|
|
333
|
+
// The pages: the overview (view 0) and the Panel tab (view 'panel'). The other views are judges.
|
|
334
|
+
export const PANEL = 'panel';
|
|
335
|
+
export const isPage = (view) => view === 0 || view === PANEL;
|
|
336
|
+
|
|
270
337
|
/**
|
|
271
338
|
* The three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
|
|
272
|
-
* the elapsed time at the left and the keys of the focus at the right. The
|
|
339
|
+
* the elapsed time at the left and the keys of the focus at the right. The tabs are Overview, Panel, then the
|
|
273
340
|
* name of each criterion, with a grey │ between the tabs. A running tab glimmers. The names step down to the
|
|
274
341
|
* first word when they do not fit the width. focus is 'strip' (the active tab is bold orange, the arrows move
|
|
275
|
-
* it), '
|
|
342
|
+
* it), 'text' (the text pane of the Panel tab scrolls), or 'judge' (the active tab is bold ink, the keys go to
|
|
276
343
|
* the judge). confirm replaces the progress with the quit question.
|
|
277
344
|
*/
|
|
278
345
|
export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', elapsed = 0, confirm = false }) {
|
|
279
346
|
const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
|
|
280
347
|
const keys = focus === 'judge' ? 'ctrl-q tabs'
|
|
281
|
-
: focus === '
|
|
282
|
-
: view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit'
|
|
283
|
-
|
|
348
|
+
: focus === 'text' ? '↑ ↓ scroll · enter list · ← → tabs · q q quit'
|
|
349
|
+
: view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit'
|
|
350
|
+
: view === PANEL ? '↑ ↓ criterion · enter text · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
|
|
351
|
+
const names = (level) => ['Overview', 'Panel', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
|
|
284
352
|
const used = (list) => 1 + list.reduce((n, t) => n + t.length, 0) + 3 * (list.length - 1);
|
|
285
353
|
const list = [0, 1].map(names).find((l) => used(l) <= width) ?? names(1);
|
|
354
|
+
const activeIndex = view === 0 ? 0 : view === PANEL ? 1 : view + 1;
|
|
286
355
|
let line = ' ';
|
|
287
356
|
let col = 1;
|
|
288
357
|
list.forEach((text, i) => {
|
|
289
358
|
if (i) { line += `${GREY} │ ${RESET}`; col += 3; }
|
|
290
|
-
const active = i ===
|
|
291
|
-
const row = rows[i -
|
|
359
|
+
const active = i === activeIndex;
|
|
360
|
+
const row = rows[i - 2];
|
|
292
361
|
const start = col;
|
|
293
362
|
col += text.length;
|
|
294
363
|
if (row?.state === 'running' && !row.trust) {
|
|
295
364
|
line += glimmer(text, frame, start, width, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
|
|
296
365
|
return;
|
|
297
366
|
}
|
|
298
|
-
line += `${active ? (focus === '
|
|
367
|
+
line += `${active ? (focus === 'judge' ? BOLD : `${ORANGE}${BOLD}`) : GREY}${text}${RESET}`;
|
|
299
368
|
});
|
|
300
369
|
const progress = confirm ? 'judges still run. q again quits, another key stays' : `${finished} of ${rows.length} done · ${clock(elapsed)}`;
|
|
301
370
|
// A narrow terminal drops the keys before the progress, and then the end of the progress.
|
|
@@ -307,50 +376,51 @@ export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', ela
|
|
|
307
376
|
/**
|
|
308
377
|
* The next { view, confirm, focus, cursor, scroll } after one key, plus send: true when the key goes to the judge,
|
|
309
378
|
* quit: true, restart: N to restart judge N, or flip: N to flip the harness of judge N. view is 0 for the
|
|
310
|
-
* overview, or N for judge N. open holds the ranks of the judges that have started,
|
|
311
|
-
* criteria, maxScroll the lines the
|
|
312
|
-
* focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the
|
|
313
|
-
* focus on the strip, the arrows left and right move the active tab and switch the view, 1-9
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* the judge of the cursor row
|
|
317
|
-
* and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus
|
|
379
|
+
* overview, PANEL for the Panel tab, or N for judge N. open holds the ranks of the judges that have started,
|
|
380
|
+
* count the number of criteria, maxScroll the lines the text pane can hide, page the lines one PgUp or PgDn
|
|
381
|
+
* moves. With the focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the
|
|
382
|
+
* strip. With the focus on the strip, the arrows left and right move the active tab and switch the view, 1-9
|
|
383
|
+
* jump to a started judge, 0 to the overview, and q quits (a second q confirms while a judge runs). On the
|
|
384
|
+
* overview and the Panel tab, which take no text, up and down move the shared cursor; on the overview r
|
|
385
|
+
* restarts the judge of the cursor row and h flips its harness; on the Panel tab Enter moves the focus into
|
|
386
|
+
* the text pane, where up and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus.
|
|
387
|
+
* On a judge tab, Enter, Esc, or ctrl-q give the focus to the judge.
|
|
318
388
|
*/
|
|
319
|
-
export function press({ view, confirm, focus, cursor, scroll = 0 }, key, { open, running, count, maxScroll: limit = 0, page = 10 }) {
|
|
389
|
+
export function press({ view, confirm, focus, cursor, scroll = 0 }, key, { open, running, count, maxScroll: limit = 0, page: step = 10 }) {
|
|
320
390
|
const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor, scroll });
|
|
321
391
|
if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, scroll, send: true };
|
|
322
392
|
if (confirm) return { ...strip(0), quit: key === 'q' };
|
|
323
393
|
if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor, scroll } : { ...strip(0), quit: true };
|
|
324
394
|
if (RIGHT.includes(key) || LEFT.includes(key)) {
|
|
325
|
-
const cycle = [0, ...open];
|
|
326
|
-
const
|
|
327
|
-
return strip(cycle[(cycle.indexOf(view) +
|
|
395
|
+
const cycle = [0, PANEL, ...open];
|
|
396
|
+
const dir = RIGHT.includes(key) ? 1 : -1;
|
|
397
|
+
return strip(cycle[(cycle.indexOf(view) + dir + cycle.length) % cycle.length]);
|
|
328
398
|
}
|
|
329
399
|
if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : { ...strip(view), focus };
|
|
330
400
|
if (key === '0') return strip(0);
|
|
331
|
-
if (view
|
|
332
|
-
|
|
333
|
-
if (key === '
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
if (UP.includes(key)) return
|
|
337
|
-
if (DOWN.includes(key)) return
|
|
338
|
-
if (key === '\x1b[5~') return
|
|
339
|
-
if (key === '\x1b[6~') return
|
|
340
|
-
if (key === '\r' || key === '\x1b') return strip(
|
|
341
|
-
return
|
|
401
|
+
if (isPage(view)) {
|
|
402
|
+
if (view === 0 && key === 'r') return open.includes(cursor + 1) ? { ...strip(0), restart: cursor + 1 } : strip(0);
|
|
403
|
+
if (view === 0 && key === 'h') return { ...strip(0), flip: cursor + 1 };
|
|
404
|
+
if (focus === 'text') {
|
|
405
|
+
const text = (s) => ({ view, confirm: false, focus: 'text', cursor, scroll: Math.max(0, Math.min(limit, s)) });
|
|
406
|
+
if (UP.includes(key)) return text(scroll - 1);
|
|
407
|
+
if (DOWN.includes(key)) return text(scroll + 1);
|
|
408
|
+
if (key === '\x1b[5~') return text(scroll - step);
|
|
409
|
+
if (key === '\x1b[6~') return text(scroll + step);
|
|
410
|
+
if (key === '\r' || key === '\x1b') return strip(view);
|
|
411
|
+
return text(scroll);
|
|
342
412
|
}
|
|
343
|
-
if (UP.includes(key)) return { ...strip(
|
|
344
|
-
if (DOWN.includes(key)) return { ...strip(
|
|
345
|
-
if (key === '\r') return
|
|
346
|
-
return strip(
|
|
413
|
+
if (UP.includes(key)) return { ...strip(view), cursor: Math.max(0, cursor - 1), scroll: 0 };
|
|
414
|
+
if (DOWN.includes(key)) return { ...strip(view), cursor: Math.min(count - 1, cursor + 1), scroll: 0 };
|
|
415
|
+
if (view === PANEL && key === '\r') return { view, confirm: false, focus: 'text', cursor, scroll: 0 };
|
|
416
|
+
return strip(view);
|
|
347
417
|
}
|
|
348
418
|
if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor, scroll };
|
|
349
419
|
return strip(view);
|
|
350
420
|
}
|
|
351
421
|
|
|
352
422
|
/** Runs the judges, shows the overview, and writes the report. Resolves when the engineer quits. */
|
|
353
|
-
export function runTable({ panel, runDir, harness, cwd, target,
|
|
423
|
+
export function runTable({ panel, runDir, harness, cwd, target, yolo = false }) {
|
|
354
424
|
const out = process.stdout;
|
|
355
425
|
const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
|
|
356
426
|
const judges = [];
|
|
@@ -395,7 +465,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
395
465
|
}
|
|
396
466
|
|
|
397
467
|
function start(rank, restartedAt = null) {
|
|
398
|
-
const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target,
|
|
468
|
+
const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target, yolo });
|
|
399
469
|
const { cols, rows: lines } = size();
|
|
400
470
|
const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
|
|
401
471
|
const serializer = new SerializeAddon();
|
|
@@ -441,7 +511,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
441
511
|
const lines = renderStrip({ rows, view: ui.view, frame, width: out.columns, focus: ui.focus, elapsed: seconds(started), confirm: ui.confirm });
|
|
442
512
|
const top = out.rows - STRIP_ROWS + 1;
|
|
443
513
|
const strip = lines.map((l, i) => `\x1b[${top + i};1H${l}\x1b[K`).join('');
|
|
444
|
-
if (
|
|
514
|
+
if (isPage(ui.view)) return void out.write(`${RESET}${strip}`);
|
|
445
515
|
const j = judges[ui.view - 1];
|
|
446
516
|
const { cursorX, cursorY } = j.term.buffer.active;
|
|
447
517
|
if (cursorX >= out.columns) return;
|
|
@@ -475,7 +545,8 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
475
545
|
|
|
476
546
|
let frame = 0;
|
|
477
547
|
function draw() {
|
|
478
|
-
const
|
|
548
|
+
const common = { 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 };
|
|
549
|
+
const lines = ui.view === PANEL ? renderPanel({ ...common, focus: ui.focus === 'text' ? 'text' : 'list', scroll: ui.scroll }) : renderOverview({ ...common, frame });
|
|
479
550
|
while (lines.length < out.rows - STRIP_ROWS) lines.push('');
|
|
480
551
|
out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
|
|
481
552
|
drawStrip();
|
|
@@ -494,14 +565,14 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
494
565
|
}
|
|
495
566
|
if (rows.every((r) => r.state === 'done' || r.state === 'error')) finish();
|
|
496
567
|
}
|
|
497
|
-
if (
|
|
568
|
+
if (isPage(ui.view)) draw();
|
|
498
569
|
}
|
|
499
570
|
|
|
500
571
|
return new Promise((done) => {
|
|
501
572
|
const timer = setInterval(tick, 1000);
|
|
502
573
|
const spinner = setInterval(() => {
|
|
503
574
|
frame += 1;
|
|
504
|
-
if (
|
|
575
|
+
if (isPage(ui.view)) draw();
|
|
505
576
|
else if (Date.now() - lastData > 60) drawStrip();
|
|
506
577
|
}, 100);
|
|
507
578
|
out.write('\x1b[?1049h\x1b[?25l');
|
|
@@ -510,7 +581,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
510
581
|
const key = data.toString();
|
|
511
582
|
const from = ui.view;
|
|
512
583
|
const height = out.rows - STRIP_ROWS;
|
|
513
|
-
const limit = rows.length ?
|
|
584
|
+
const limit = rows.length ? panelScrollMax({ rows, cursor: ui.cursor, width: out.columns, height }) : 0;
|
|
514
585
|
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
586
|
ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor, scroll: next.scroll };
|
|
516
587
|
if (next.send) {
|
|
@@ -529,13 +600,13 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
529
600
|
} else if (next.flip) {
|
|
530
601
|
harnesses[next.flip - 1] = harnesses[next.flip - 1] === 'claude' ? 'codex' : 'claude';
|
|
531
602
|
tick();
|
|
532
|
-
} else if (ui.view && ui.view === from) {
|
|
603
|
+
} else if (!isPage(ui.view) && ui.view === from) {
|
|
533
604
|
drawStrip();
|
|
534
605
|
} else {
|
|
535
606
|
// A judge can leave its modes on (mouse, bracketed paste, the kitty keyboard). The next view starts clean.
|
|
536
|
-
if (from) out.write(TABLE_MODES);
|
|
537
|
-
if (ui.view)
|
|
538
|
-
else
|
|
607
|
+
if (!isPage(from)) out.write(TABLE_MODES);
|
|
608
|
+
if (isPage(ui.view)) draw();
|
|
609
|
+
else showJudge(ui.view);
|
|
539
610
|
}
|
|
540
611
|
});
|
|
541
612
|
out.on('resize', () => {
|
|
@@ -544,8 +615,8 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
|
|
|
544
615
|
if (j && !j.exited) j.proc.resize(cols, lines);
|
|
545
616
|
j?.term.resize(cols, lines);
|
|
546
617
|
}
|
|
547
|
-
if (ui.view)
|
|
548
|
-
else
|
|
618
|
+
if (isPage(ui.view)) draw();
|
|
619
|
+
else showJudge(ui.view);
|
|
549
620
|
});
|
|
550
621
|
tick();
|
|
551
622
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xsft/panels",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Run a panel of AI judges from cross.software/panels against a target.",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@homebridge/node-pty-prebuilt-multiarch": "0.14.1",
|
|
26
26
|
"@xterm/addon-serialize": "0.14.0",
|
|
27
|
-
"@xterm/headless": "6.0.0"
|
|
28
|
-
"figlet": "1.12.0"
|
|
27
|
+
"@xterm/headless": "6.0.0"
|
|
29
28
|
}
|
|
30
29
|
}
|
|
@@ -3,44 +3,42 @@ You are one judge in a run of a panel. A panel is a named list of criteria. You
|
|
|
3
3
|
YOUR INPUTS
|
|
4
4
|
|
|
5
5
|
The user message gives you:
|
|
6
|
-
- Your criterion: its rank, its name, its one-line description, and its body.
|
|
6
|
+
- Your criterion: its rank, its name, its one-line description, and its body. They define what to assess, not how. This prompt holds the method.
|
|
7
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
8
|
|
|
11
9
|
Your working directory is the folder where the engineer ran the command.
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
HOW TO JUDGE
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
- Look at the target itself: open it, read it, and run it when it is code. Reach it with your tools: read files, run commands, open URLs.
|
|
14
|
+
- Collect evidence for every claim you make: a URL, a file and line, a quote, or a command and its output. The engineer must be able to check each one.
|
|
15
|
+
- Give no credit for intent. A comment or a doc is not done work.
|
|
16
|
+
- When you cannot reach the target, or cannot check a part of the criterion with your tools, say so at the end of your verdict after "Not checked:" with the reason. Never score what you did not see.
|
|
17
|
+
- Keep any notes or tables for yourself; they do not go in the answer.
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
SCORE
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
The score is a whole number from 1 to 4. Only 4 passes.
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
4 Met. The criterion holds across the whole target, every claim has evidence, and a real look found no material flaw.
|
|
24
|
+
3 Almost met. The criterion holds in the main. Material flaws remain, and each one is named with its evidence.
|
|
25
|
+
2 Falls short. Parts hold, but the flaws outweigh them, or a core part is missing.
|
|
26
|
+
1 Fails. The criterion does not hold, or the target cannot be assessed for it, or a claim is invented.
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
3. When the evidence fits two levels, use the lower level.
|
|
26
|
-
4. A criterion passes only at score 4.
|
|
27
|
-
|
|
28
|
-
EVIDENCE
|
|
29
|
-
|
|
30
|
-
- Each claim needs evidence that the engineer can check: a URL, a file and line, a quote, or a command and its output. Put the place in brackets inside the verdict, such as (index.html:90).
|
|
31
|
-
- Give no credit for intent. A promise in a comment, a doc, or the brief is not done work.
|
|
28
|
+
Rules:
|
|
29
|
+
- When the evidence fits two levels, use the lower level.
|
|
32
30
|
- No evidence for a level means the lower level.
|
|
31
|
+
- Anything you did not check cannot support a 4. List it at the end of the verdict after "Not checked:".
|
|
33
32
|
|
|
34
33
|
THE VERDICT
|
|
35
34
|
|
|
36
|
-
One paragraph: what holds, then what is wrong and where, each place in brackets, then "Not checked:" and the
|
|
35
|
+
One paragraph: what holds, then what is wrong and where, each place in brackets such as (index.html:90), then "Not checked:" and the parts you could not check, when there are any. Judge; do not propose fixes. The fixes are the job of the engineer.
|
|
37
36
|
|
|
38
37
|
PROHIBITIONS
|
|
39
38
|
|
|
40
|
-
- Do not change the
|
|
41
|
-
- Do not score a fault that
|
|
42
|
-
- Do not write or change files
|
|
43
|
-
- Do not start sub-agents.
|
|
39
|
+
- Do not change the criterion or the levels.
|
|
40
|
+
- Do not score a fault that another criterion of the panel covers.
|
|
41
|
+
- Do not write or change files of the target. The answer file, when the user message names one, is the only file you write.
|
|
44
42
|
- Do not submit forms, send messages, or post data.
|
|
45
43
|
- Do not read the .panels folder. It holds earlier reports.
|
|
46
44
|
|
|
@@ -59,5 +57,5 @@ Example:
|
|
|
59
57
|
{
|
|
60
58
|
"name": "Original design assets",
|
|
61
59
|
"score": 2,
|
|
62
|
-
"verdict": "The hero shows the real app, and the cutaway hotel and the team scenes are original and share one idea (public/assets/hotel-world). Every content icon is a generic Lucide symbol (src/components/Features.tsx:3),
|
|
60
|
+
"verdict": "The hero shows the real app, and the cutaway hotel and the team scenes are original and share one idea (public/assets/hotel-world). Every content icon is a generic Lucide symbol (src/components/Features.tsx:3), so the icon set is not original. Not checked: a reverse image search of hero.webp, the target is on localhost."
|
|
63
61
|
}
|