@xsft/panels 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/bin/panels.js +28 -6
- package/lib/args.js +0 -4
- package/lib/judge.js +34 -3
- package/lib/table.js +119 -0
- package/package.json +1 -1
- package/lib/split.js +0 -14
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @xsft/panels
|
|
2
2
|
|
|
3
|
-
`panels run` runs a panel from cross.software/panels against a target. Each judge is a `claude` or `codex` session that scores one
|
|
3
|
+
`panels run` runs a panel from cross.software/panels against a target. Each judge is a `claude` or `codex` session that scores one criterion of the panel. You need Node 20 or newer and one of the two harnesses.
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
6
|
panels run <panel> [target...] [flags]
|
|
@@ -8,7 +8,6 @@ panels run <panel> [target...] [flags]
|
|
|
8
8
|
<panel> owner/name, or owner/name/vN to pin version N
|
|
9
9
|
[target...] free words, joined with single spaces into one target
|
|
10
10
|
|
|
11
|
-
--size <n> number of judges default: the number of criteria
|
|
12
11
|
--harness <name> claude | codex default: claude if installed, else codex
|
|
13
12
|
--brief <file> the brief default: none
|
|
14
13
|
--sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
|
|
@@ -26,7 +25,13 @@ npx @xsft/panels run xsft/marketing-site https://example.com --brief docs/brief.
|
|
|
26
25
|
npx @xsft/panels run xsft/marketing-site/v6 . at http://localhost:4321 --sources docs --harness codex
|
|
27
26
|
```
|
|
28
27
|
|
|
29
|
-
Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes
|
|
28
|
+
Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes.
|
|
29
|
+
|
|
30
|
+
## tmux mode
|
|
31
|
+
|
|
32
|
+
On a terminal with `tmux` on PATH, `panels run` opens a tmux session: window 0 shows the scores, and window N runs judge N in the interactive harness. Each judge writes `judges/N/answer.json` and stays open; window 0 writes the report when each judge has an answer or an error, and the command exits from `report.json` when you leave the session. Without a terminal or tmux, the judges run in the background (`claude -p`, `codex exec`).
|
|
33
|
+
|
|
34
|
+
Keys in window 0: `1` to `9` open the window of that judge (tmux `prefix 0` comes back), `q` ends the session and asks `y` while a judge still runs.
|
|
30
35
|
|
|
31
36
|
## Run folder
|
|
32
37
|
|
|
@@ -34,7 +39,8 @@ Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
|
|
|
34
39
|
|
|
35
40
|
- `panel.json`: the panel as the api gave it.
|
|
36
41
|
- `report.md` and `report.json`: the scores, the evidence, the fixes, and the errors. `schema/report.json` is the schema of `report.json`.
|
|
37
|
-
- `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k.
|
|
42
|
+
- `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k. In tmux mode, `start.sh` and `answer.json` take the place of the raw answer.
|
|
43
|
+
- `run.json`, in tmux mode: the session name, the harness, the folder, and the target, for window 0.
|
|
38
44
|
|
|
39
45
|
The last line on stdout is the path of `report.json`.
|
|
40
46
|
|
|
@@ -48,4 +54,4 @@ The last line on stdout is the path of `report.json`.
|
|
|
48
54
|
- `--model`: each harness uses its default.
|
|
49
55
|
- A panel URL as `<panel>`, a `PANELS_API` override, and a panel cache.
|
|
50
56
|
- A login: an owner cannot run an unpublished panel.
|
|
51
|
-
-
|
|
57
|
+
- Cost totals, and `cap` and `gap` fields.
|
package/bin/panels.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { parse, SYNOPSIS } from '../lib/args.js';
|
|
5
|
-
import { runJudge } from '../lib/judge.js';
|
|
7
|
+
import { runJudge, writeJudge } from '../lib/judge.js';
|
|
6
8
|
import { fetchPanel } from '../lib/panel.js';
|
|
7
9
|
import { buildReport, renderMarkdown } from '../lib/report.js';
|
|
8
|
-
import { split } from '../lib/split.js';
|
|
9
10
|
|
|
10
11
|
// More parallel sessions on one account get 429 answers from the model api.
|
|
11
12
|
const PARALLEL_JUDGES = 4;
|
|
@@ -25,6 +26,25 @@ async function pool(items, size, fn) {
|
|
|
25
26
|
return out;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
const EXIT = { pass: 0, fail: 1, error: 2 };
|
|
30
|
+
|
|
31
|
+
/** Starts the scores window in a new tmux session, attaches to it, and exits from report.json when the attach returns. */
|
|
32
|
+
function runInTmux({ panel, runDir, time, harness, cwd, args }) {
|
|
33
|
+
const session = `panels-${time.slice(11, 16)}`;
|
|
34
|
+
writeFileSync(join(runDir, 'run.json'), JSON.stringify({ session, harness, cwd, target: args.target }, null, 2));
|
|
35
|
+
panel.criteria.forEach((c, i) => writeJudge({ dir: join(runDir, 'judges', String(i + 1)), criteria: [{ rank: i + 1, ...c }], harness, cwd, target: args.target, brief: args.brief, sources: args.sources }));
|
|
36
|
+
execFileSync('tmux', ['new-session', '-d', '-s', session, '-n', 'scores', process.execPath, fileURLToPath(new URL('../lib/table.js', import.meta.url)), runDir]);
|
|
37
|
+
if (process.env.TMUX) {
|
|
38
|
+
execFileSync('tmux', ['switch-client', '-t', session]);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
spawnSync('tmux', ['attach-session', '-t', session], { stdio: 'inherit' });
|
|
42
|
+
const reportPath = join(runDir, 'report.json');
|
|
43
|
+
if (!existsSync(reportPath)) return 2;
|
|
44
|
+
console.log(reportPath);
|
|
45
|
+
return EXIT[JSON.parse(readFileSync(reportPath, 'utf8')).status];
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
async function main() {
|
|
29
49
|
const args = parse(process.argv.slice(2));
|
|
30
50
|
if (args.help) {
|
|
@@ -51,10 +71,12 @@ async function main() {
|
|
|
51
71
|
}
|
|
52
72
|
const { panel } = fetched;
|
|
53
73
|
const cwd = process.cwd();
|
|
54
|
-
const judges =
|
|
55
|
-
const
|
|
74
|
+
const judges = panel.criteria.map((_, i) => [i]);
|
|
75
|
+
const time = new Date().toISOString().replaceAll(':', '-');
|
|
76
|
+
const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, time);
|
|
56
77
|
mkdirSync(runDir, { recursive: true });
|
|
57
78
|
writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
|
|
79
|
+
if (process.stdout.isTTY && process.stdin.isTTY && onPath('tmux')) return runInTmux({ panel, runDir, time, harness, cwd, args });
|
|
58
80
|
const outcomes = await pool(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
|
|
59
81
|
dir: join(runDir, 'judges', String(j + 1)),
|
|
60
82
|
criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
|
|
@@ -71,7 +93,7 @@ async function main() {
|
|
|
71
93
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
72
94
|
console.log(markdown);
|
|
73
95
|
console.log(reportPath);
|
|
74
|
-
return
|
|
96
|
+
return EXIT[report.status];
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
// Node exits 1 on a crash, and 1 means "fail" to an agent loop, so a crash prints its stack and exits 2.
|
package/lib/args.js
CHANGED
|
@@ -5,7 +5,6 @@ export const SYNOPSIS = `panels run <panel> [target...] [flags]
|
|
|
5
5
|
<panel> owner/name, or owner/name/vN to pin version N
|
|
6
6
|
[target...] free words, joined with single spaces into one target
|
|
7
7
|
|
|
8
|
-
--size <n> number of judges default: the number of criteria
|
|
9
8
|
--harness <name> claude | codex default: claude if installed, else codex
|
|
10
9
|
--brief <file> the brief default: none
|
|
11
10
|
--sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
|
|
@@ -16,7 +15,6 @@ exit 1 fail: every judge answered, and one or more criteria scored below 4
|
|
|
16
15
|
exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed`;
|
|
17
16
|
|
|
18
17
|
const options = {
|
|
19
|
-
size: { type: 'string' },
|
|
20
18
|
harness: { type: 'string' },
|
|
21
19
|
brief: { type: 'string' },
|
|
22
20
|
sources: { type: 'string', multiple: true },
|
|
@@ -39,12 +37,10 @@ export function parse(argv) {
|
|
|
39
37
|
const match = PANEL.exec(panel);
|
|
40
38
|
if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name/vN` };
|
|
41
39
|
if (words.length === 0) return { error: 'no target: type what the panel reviews after the panel name, such as a URL, a path, or a branch' };
|
|
42
|
-
if (values.size !== undefined && !/^[1-9]\d*$/.test(values.size)) return { error: `--size needs a whole number of 1 or more, not ${values.size}` };
|
|
43
40
|
if (values.harness !== undefined && !['claude', 'codex'].includes(values.harness)) return { error: `--harness needs claude or codex, not ${values.harness}` };
|
|
44
41
|
return {
|
|
45
42
|
panel: { owner: match[1], name: match[2], version: match[3] && Number(match[3]) },
|
|
46
43
|
target: words.join(' '),
|
|
47
|
-
size: values.size && Number(values.size),
|
|
48
44
|
harness: values.harness,
|
|
49
45
|
brief: values.brief,
|
|
50
46
|
sources: values.sources ?? [],
|
package/lib/judge.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
|
|
5
6
|
const { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
|
|
6
|
-
const
|
|
7
|
-
const
|
|
7
|
+
const SYSTEM_PROMPT_FILE = fileURLToPath(new URL('../prompts/judge-system-prompt.txt', import.meta.url));
|
|
8
|
+
const SYSTEM_PROMPT = readFileSync(SYSTEM_PROMPT_FILE, 'utf8');
|
|
9
|
+
export const MINUTES_PER_CRITERION = 20;
|
|
8
10
|
const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
|
|
9
11
|
// With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
|
|
10
12
|
const ALLOWED_TOOLS = 'Read,Glob,Grep,WebFetch,WebSearch,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git status:*),Bash(git ls-files:*),Bash(git rev-parse:*),Bash(git branch --list:*)';
|
|
@@ -112,7 +114,7 @@ async function askCodex(j, prompt, thread) {
|
|
|
112
114
|
return { answer: parseAnswer(readFileSync(raw, 'utf8')), session: thread ?? JSON.parse(started).thread_id };
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
function parseAnswer(text) {
|
|
117
|
+
export function parseAnswer(text) {
|
|
116
118
|
try {
|
|
117
119
|
return JSON.parse(text);
|
|
118
120
|
} catch {
|
|
@@ -143,3 +145,32 @@ export async function runJudge({ dir, criteria, harness, cwd, target, brief, sou
|
|
|
143
145
|
}
|
|
144
146
|
return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
|
|
145
147
|
}
|
|
148
|
+
|
|
149
|
+
/** Quotes a string for sh. */
|
|
150
|
+
export const sq = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
151
|
+
|
|
152
|
+
const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
|
|
153
|
+
|
|
154
|
+
/** A TOML basic string, for the value of a codex -c flag. */
|
|
155
|
+
export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOML_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
|
|
156
|
+
|
|
157
|
+
/** The shell command that starts one judge of the tmux mode in its interactive harness. */
|
|
158
|
+
export function startScript({ dir, harness, cwd, brief, sources }) {
|
|
159
|
+
const prompt = `"$(cat ${sq(join(dir, 'prompt.md'))})"`;
|
|
160
|
+
if (harness === 'codex') {
|
|
161
|
+
return `codex -C ${sq(dir)} -s workspace-write --disable multi_agent --disable apps -c ${sq(`developer_instructions=${tomlString(SYSTEM_PROMPT)}`)} ${prompt}\n`;
|
|
162
|
+
}
|
|
163
|
+
const add = addDirs(cwd, brief, sources).map((d) => ` --add-dir ${sq(d)}`).join('');
|
|
164
|
+
// Claude matches file paths only in Edit rules, which cover Write too; // starts an absolute path.
|
|
165
|
+
const allowed = `${ALLOWED_TOOLS},Edit(/${join(dir, 'answer.json')})`;
|
|
166
|
+
return `claude --safe-mode --system-prompt "$(cat ${sq(SYSTEM_PROMPT_FILE)})" --tools ${TOOLS},Write${add} --allowedTools ${sq(allowed)} --permission-mode dontAsk ${prompt}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Writes prompt.md, schema.json, and start.sh of one judge of the tmux mode. */
|
|
170
|
+
export function writeJudge({ dir, criteria, harness, cwd, target, brief, sources }) {
|
|
171
|
+
mkdirSync(dir, { recursive: true });
|
|
172
|
+
const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer JSON, which matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
|
|
173
|
+
writeFileSync(join(dir, 'prompt.md'), `${userMessage(criteria, { target, brief, sources })}\n${closing}`);
|
|
174
|
+
writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
|
|
175
|
+
writeFileSync(join(dir, 'start.sh'), startScript({ dir, harness, cwd, brief, sources }));
|
|
176
|
+
}
|
package/lib/table.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Window 0 of the tmux mode: `node table.js <run folder>`. It starts the judge windows, shows the scores, and writes the report.
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { check, MINUTES_PER_CRITERION, parseAnswer } from './judge.js';
|
|
7
|
+
import { buildReport, renderMarkdown } from './report.js';
|
|
8
|
+
|
|
9
|
+
// More parallel sessions on one account get 429 answers from the model api.
|
|
10
|
+
const LIVE_JUDGES = 4;
|
|
11
|
+
const ORANGE = '\x1b[38;2;255;106;61m';
|
|
12
|
+
const GREY = '\x1b[90m';
|
|
13
|
+
const BOLD = '\x1b[1m';
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The lines of window 0. rows hold { rank, name, state, result }, and state is queued, running, done, or error.
|
|
18
|
+
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
19
|
+
*/
|
|
20
|
+
export function render({ title, rows, final, confirm, width }) {
|
|
21
|
+
const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
|
|
22
|
+
const pad = Math.max(...rows.map((r) => r.name.length)) + 2;
|
|
23
|
+
const lines = [` ${BOLD}panels run${RESET} ${title}`, rule];
|
|
24
|
+
for (const r of rows) {
|
|
25
|
+
const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
|
|
26
|
+
if (r.state === 'running') lines.push(`${ORANGE}${head}…${RESET}`);
|
|
27
|
+
else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
|
|
28
|
+
else if (r.state === 'error') lines.push(`${head}error`);
|
|
29
|
+
else lines.push(`${head}${r.result.score} ${GREY}b${r.result.band}${RESET}`);
|
|
30
|
+
}
|
|
31
|
+
lines.push(rule);
|
|
32
|
+
if (final) {
|
|
33
|
+
const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
|
|
34
|
+
lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push(confirm ? ' judges still run. y quits, another key stays' : `${GREY} 1-${Math.min(rows.length, 9)} open judge q quit${RESET}`);
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function main(runDir) {
|
|
41
|
+
const read = (file) => JSON.parse(readFileSync(join(runDir, file), 'utf8'));
|
|
42
|
+
const panel = read('panel.json');
|
|
43
|
+
const { session, harness, cwd, target } = read('run.json');
|
|
44
|
+
const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
|
|
45
|
+
const started = [];
|
|
46
|
+
let rows = [];
|
|
47
|
+
let final = null;
|
|
48
|
+
let confirm = false;
|
|
49
|
+
|
|
50
|
+
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
51
|
+
|
|
52
|
+
function state(c, i) {
|
|
53
|
+
const file = join(judgeDir(c.rank), 'answer.json');
|
|
54
|
+
if (existsSync(file)) {
|
|
55
|
+
const answer = parseAnswer(readFileSync(file, 'utf8'));
|
|
56
|
+
const errors = check(answer, [c]);
|
|
57
|
+
return errors.length ? { state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { state: 'done', result: answer.results[0] };
|
|
58
|
+
}
|
|
59
|
+
if (started[i] === undefined) return { state: 'queued' };
|
|
60
|
+
if (Date.now() - started[i] > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
|
|
61
|
+
return { state: 'running' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function start(rank) {
|
|
65
|
+
started[rank - 1] = Date.now();
|
|
66
|
+
execFileSync('tmux', ['new-window', '-d', '-t', `${session}:${rank}`, '-n', `judge-${rank}`, '-c', cwd, 'sh', join(judgeDir(rank), 'start.sh')]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function finish() {
|
|
70
|
+
const outcomes = rows.map((r) => (r.state === 'done' ? { results: [r.result] } : { error: r.error }));
|
|
71
|
+
const report = buildReport({ panel, target, cwd, harness, judges: rows.map((_, i) => [i]), outcomes });
|
|
72
|
+
const reportPath = join(runDir, 'report.json');
|
|
73
|
+
writeFileSync(join(runDir, 'report.md'), renderMarkdown(report));
|
|
74
|
+
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
75
|
+
final = { status: report.status, path: relative(cwd, reportPath) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function draw() {
|
|
79
|
+
const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm, width: Math.min(process.stdout.columns, 100) });
|
|
80
|
+
process.stdout.write(`\x1b[H${lines.map((l) => `${l}\x1b[K`).join('\n')}\x1b[J`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tick() {
|
|
84
|
+
if (!final) {
|
|
85
|
+
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
86
|
+
const live = rows.filter((r) => r.state === 'running').length;
|
|
87
|
+
for (const r of rows.filter((x) => x.state === 'queued').slice(0, LIVE_JUDGES - live)) {
|
|
88
|
+
start(r.rank);
|
|
89
|
+
r.state = 'running';
|
|
90
|
+
}
|
|
91
|
+
if (rows.every((r) => r.state === 'done' || r.state === 'error')) finish();
|
|
92
|
+
}
|
|
93
|
+
draw();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const quit = () => execFileSync('tmux', ['kill-session', '-t', session]);
|
|
97
|
+
|
|
98
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
99
|
+
process.stdin.setRawMode(true);
|
|
100
|
+
process.stdin.on('data', (data) => {
|
|
101
|
+
const key = data.toString();
|
|
102
|
+
if (confirm) {
|
|
103
|
+
confirm = false;
|
|
104
|
+
if (key === 'y') quit();
|
|
105
|
+
} else if (key === 'q') {
|
|
106
|
+
if (rows.some((r) => r.state === 'running')) confirm = true;
|
|
107
|
+
else quit();
|
|
108
|
+
} else if (/^[1-9]$/.test(key) && Number(key) <= rows.length) {
|
|
109
|
+
// A queued judge has no window yet, so tmux fails, and its message goes to the ignored pipe.
|
|
110
|
+
spawnSync('tmux', ['select-window', '-t', `${session}:${key}`]);
|
|
111
|
+
}
|
|
112
|
+
draw();
|
|
113
|
+
});
|
|
114
|
+
process.stdout.on('resize', draw);
|
|
115
|
+
tick();
|
|
116
|
+
setInterval(tick, 1000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv[2]);
|
package/package.json
CHANGED
package/lib/split.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/** Returns, for each judge, the indexes of the criteria it scores. n is the number of criteria. */
|
|
2
|
-
export function split(n, size) {
|
|
3
|
-
if (size <= n) {
|
|
4
|
-
const judges = [];
|
|
5
|
-
let start = 0;
|
|
6
|
-
for (let j = 0; j < size; j++) {
|
|
7
|
-
const length = Math.floor(n / size) + (j < n % size ? 1 : 0);
|
|
8
|
-
judges.push(Array.from({ length }, (_, x) => start + x));
|
|
9
|
-
start += length;
|
|
10
|
-
}
|
|
11
|
-
return judges;
|
|
12
|
-
}
|
|
13
|
-
return Array.from({ length: n }, (_, i) => Array(Math.floor(size / n) + (i < size % n ? 1 : 0)).fill([i])).flat();
|
|
14
|
-
}
|