@xsft/panels 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ Run the command in the folder of the product. Each judge starts in that folder,
31
31
 
32
32
  On a terminal, `panels run` shows the score table, and each judge runs in its interactive harness in a pseudo-terminal inside the command. Each judge writes `judges/N/answer.json` and stays open. The table writes the report when each judge has an answer or an error, and the command exits from `report.json` when you quit. Without a terminal, the judges run in the background (`claude -p`, `codex exec`).
33
33
 
34
- A tab strip on the last three lines shows a hairline, a tab per view, and a status line with the progress, the elapsed time, and the keys. The tabs are the scores, then each judge with its number, name, and mark (score, ✗ for an error, · while queued, ! while the harness asks whether to trust the folder). The label of a running judge glimmers. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge, `0` to the scores, `enter` gives the focus to the judge, and `q` quits and asks `y` while a judge still runs. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The scores take no input, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
34
+ A tab strip on the last three lines shows a hairline, a tab per view, and a status line with the progress, the elapsed time, and the keys. The tabs are the scores, then each judge with its number, name, and mark (score, ✗ for an error, · while queued, ! while the harness asks whether to trust the folder). One soft wave sweeps across the strip and lights the labels of the running judges as it passes; the same wave sweeps the running rows of the scores table. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge, `0` to the scores, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The scores take no input, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
35
35
 
36
36
  ## Run folder
37
37
 
package/lib/judge.js CHANGED
@@ -9,8 +9,6 @@ const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
9
9
  // With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
10
10
  const ALLOWED_TOOLS = 'Read,Glob,Grep,WebFetch,WebSearch,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git status:*),Bash(git ls-files:*),Bash(git rev-parse:*),Bash(git branch --list:*)';
11
11
 
12
- export const bandToScore = (band) => (band <= 3 ? 1 : band <= 6 ? 2 : band <= 8 ? 3 : 4);
13
-
14
12
  /**
15
13
  * The user message of a judge. criteria items are { rank, name, description, body }. Each part sits
16
14
  * in a tag, because a body is markdown with headings of its own.
@@ -45,8 +43,7 @@ export function check(answer, criteria) {
45
43
  if (missing.length) return [`Item ${i + 1} lacks ${missing.join(', ')}.`];
46
44
  const errors = [];
47
45
  if (r.rank !== c.rank || r.criterion !== c.name) errors.push(`Item ${i + 1} must have rank ${c.rank} and criterion "${c.name}".`);
48
- if (!Number.isInteger(r.band) || r.band < 1 || r.band > 10) errors.push(`Rank ${c.rank}: band must be a whole number from 1 to 10.`);
49
- else if (r.score !== bandToScore(r.band)) errors.push(`Rank ${c.rank}: band ${r.band} maps to score ${bandToScore(r.band)}, not ${r.score}.`);
46
+ if (!Number.isInteger(r.score) || r.score < 1 || r.score > 4) errors.push(`Rank ${c.rank}: score must be a whole number from 1 to 4.`);
50
47
  if (r.score < 4 && !(Array.isArray(r.fixes) && r.fixes.length)) errors.push(`Rank ${c.rank}: score ${r.score} is below 4, so fixes must list each fix that score 4 needs.`);
51
48
  return errors;
52
49
  });
package/lib/report.js CHANGED
@@ -15,6 +15,8 @@ export function buildReport({ panel, target, cwd, harness, judges, outcomes }) {
15
15
  return { status, panel: { owner: panel.owner, name: panel.name, version: panel.version }, target, cwd, harness, size: judges.length, criteria, errors };
16
16
  }
17
17
 
18
+ const LEVELS = { 1: 'Fails', 2: 'Falls short', 3: 'Almost met', 4: 'Met' };
19
+
18
20
  const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
19
21
 
20
22
  export function renderMarkdown(report) {
@@ -32,7 +34,7 @@ export function renderMarkdown(report) {
32
34
  const list = (title, items) => items.length && lines.push(title, ...items.map((item) => `- ${item}`));
33
35
  for (const c of criteria) {
34
36
  for (const { result } of c.judges) {
35
- lines.push('', `## ${c.rank}. ${c.name}: ${result.score} (band ${result.band})`);
37
+ lines.push('', `## ${c.rank}. ${c.name}: ${result.score}/4 ${LEVELS[result.score]}`);
36
38
  list('Evidence', result.evidence.map((e) => `${e.where}: ${e.quote ?? e.claim}`));
37
39
  list('Fixes', result.fixes.map((f) => `${f.problem} ${f.change} (${f.where})`));
38
40
  list('Not checked', result.notChecked.map((n) => `${n.test}: ${n.reason}`));
package/lib/table.js CHANGED
@@ -31,16 +31,21 @@ const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b
31
31
  * trust is true while the harness of a running judge asks whether to trust the folder.
32
32
  * final is null until the report exists, then { status, path }. confirm is true while q waits for y.
33
33
  */
34
- // The glimmer on the text of a running judge: grey text, and a bright highlight three characters wide that
35
- // sweeps from left to right one character per frame and wraps.
36
- const GLIMMER = { base: '\x1b[38;5;244m', mid: '\x1b[38;5;250m', high: '\x1b[38;5;255m' };
37
- const GLIMMER_ORANGE = { base: ORANGE, mid: '\x1b[38;2;255;170;140m', high: '\x1b[38;5;255m' };
34
+ // The glimmer on the text of the running judges: grey text, and one soft wave three columns wide that moves one
35
+ // column per frame from the left edge of the screen to the right edge and starts over. A running label lights
36
+ // up only where the wave passes over its own columns, so every running label lights at the same columns.
37
+ const GLIMMER = { base: '\x1b[38;5;244m', mid: '\x1b[38;5;247m', high: '\x1b[38;5;250m' };
38
+ const GLIMMER_ORANGE = { base: ORANGE, mid: '\x1b[38;2;255;140;100m', high: '\x1b[38;2;255;175;145m' };
39
+ const WAVE = 3;
38
40
 
39
- /** text with the glimmer at frame. colours is { base, mid, high }; prefix (such as bold) goes before each piece. */
40
- export function glimmer(text, frame, colours = GLIMMER, prefix = '') {
41
- const centre = frame % text.length;
41
+ /**
42
+ * text with the glimmer at frame, where the text starts at the screen column start (0-based) on a screen width
43
+ * columns wide. colours is { base, mid, high }; prefix (such as bold) goes before each piece.
44
+ */
45
+ export function glimmer(text, frame, start, width, colours = GLIMMER, prefix = '') {
46
+ const centre = (frame % (width + WAVE)) - 1;
42
47
  return [...text].map((c, i) => {
43
- const d = Math.min(Math.abs(i - centre), text.length - Math.abs(i - centre));
48
+ const d = Math.abs(start + i - centre);
44
49
  return `${prefix}${d === 0 ? colours.high : d === 1 ? colours.mid : colours.base}${c}`;
45
50
  }).join('') + RESET;
46
51
  }
@@ -54,7 +59,7 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
54
59
  for (const r of rows) {
55
60
  const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
56
61
  if (r.state === 'running' && r.trust) lines.push(`${ORANGE}${head}press ${r.rank} and answer the trust prompt${RESET}`);
57
- else if (r.state === 'running') lines.push(`${String(r.rank).padStart(3)} ${glimmer(r.name, frame)}`);
62
+ else if (r.state === 'running') lines.push(`${String(r.rank).padStart(3)} ${glimmer(r.name, frame, 5, width)}`);
58
63
  else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
59
64
  else if (r.state === 'error') lines.push(`${head}error`);
60
65
  else lines.push(`${head}${r.result.score}/4`);
@@ -64,7 +69,7 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
64
69
  const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
65
70
  lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
66
71
  }
67
- if (confirm) lines.push(' judges still run. y quits, another key stays');
72
+ if (confirm) lines.push(' judges still run. q again quits, another key stays');
68
73
  return lines;
69
74
  }
70
75
 
@@ -94,14 +99,17 @@ export function renderStrip({ rows, view, final, frame = 0, width, focus = 'stri
94
99
  const used = (list) => 1 + list.reduce((n, t) => n + plain(t).length, 0) + 2 * (list.length - 1);
95
100
  const list = [0, 1, 2].map(tabs).find((l) => used(l) <= width) ?? tabs(2);
96
101
  let line = ' ';
102
+ let col = 1;
97
103
  list.forEach((t, i) => {
98
- if (i) line += ' ';
104
+ if (i) { line += ' '; col += 2; }
99
105
  const active = i === view;
100
106
  const base = active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY;
101
107
  const text = plain(t);
102
108
  const row = rows[i - 1];
109
+ const start = col;
110
+ col += text.length;
103
111
  if (row?.state === 'running' && !row.trust) {
104
- line += glimmer(text, frame, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
112
+ line += glimmer(text, frame, start, width, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
105
113
  return;
106
114
  }
107
115
  const pieces = [];
@@ -120,13 +128,13 @@ export function renderStrip({ rows, view, final, frame = 0, width, focus = 'stri
120
128
  * view is 0 for the scores, or N for judge N. open holds the ranks of the judges that have started. With the
121
129
  * focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
122
130
  * focus on the strip, the arrows move the active tab and switch the view, 1-9 jump to a started judge, 0 to the
123
- * scores, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (y confirms while a judge runs).
131
+ * scores, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (a second q confirms while a judge runs).
124
132
  * The scores take no input, so the strip keeps the focus there.
125
133
  */
126
134
  export function press({ view, confirm, focus }, key, { open, running }) {
127
135
  const strip = (v) => ({ view: v, confirm: false, focus: 'strip' });
128
136
  if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, send: true };
129
- if (confirm) return { ...strip(0), quit: key === 'y' };
137
+ if (confirm) return { ...strip(0), quit: key === 'q' };
130
138
  if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip' } : { ...strip(0), quit: true };
131
139
  if (RIGHT.includes(key) || LEFT.includes(key)) {
132
140
  const cycle = [0, ...open];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.5.2",
3
+ "version": "0.6.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",
@@ -20,22 +20,16 @@ Score your criteria one at a time, in rank order. Before you read the brief or t
20
20
 
21
21
  HOW TO SCORE
22
22
 
23
- 1. Follow the body to a level from 1 to 10. The body calls this level a score. Here it is the band.
24
- 2. Apply each cap of the body, such as "cannot support a score above 8". When two caps apply, use the lower cap.
25
- 3. When the evidence fits two bands, use the lower band.
26
- 4. Convert the band to the score with this map:
27
- band 1 to 3 = score 1
28
- band 4 to 6 = score 2
29
- band 7 or 8 = score 3
30
- band 9 or 10 = score 4
31
- 5. A criterion passes only at score 4.
23
+ 1. Follow the body to a level from 1 to 4: 4 Met, 3 Almost met, 2 Falls short, 1 Fails. The level is the score.
24
+ 2. Apply each cap of the body, such as "cannot support a score above 3". When two caps apply, use the lower cap.
25
+ 3. When the evidence fits two levels, use the lower level.
26
+ 4. A criterion passes only at score 4.
32
27
 
33
28
  EVIDENCE
34
29
 
35
30
  - Each claim needs evidence that the engineer can check: a URL, a file and line, a quote, or a command and its output.
36
31
  - Give no credit for intent. A promise in a comment, a doc, or the brief is not done work.
37
- - No evidence for a band means the lower band.
38
- - At band 9, name the one specific gap of the body in the evidence.
32
+ - No evidence for a level means the lower level.
39
33
 
40
34
  RECORDS AND FIXES
41
35
 
@@ -44,7 +38,7 @@ RECORDS AND FIXES
44
38
 
45
39
  PROHIBITIONS
46
40
 
47
- - Do not change the body, the bands, the caps, or the map.
41
+ - Do not change the body, the levels, or the caps.
48
42
  - Do not score a fault that the body gives to another criterion.
49
43
  - Do not write or change files. Name each fix, and do not make it.
50
44
  - Do not submit forms, send messages, or post data.
@@ -58,7 +52,6 @@ Return one JSON object and nothing else. Give one item in results for each of yo
58
52
  "results": [{
59
53
  "rank": integer,
60
54
  "criterion": string,
61
- "band": integer 1 to 10,
62
55
  "score": integer 1 to 4,
63
56
  "verdict": string, one sentence of 30 words or less,
64
57
  "records": [{ "name": string, "columns": [string], "rows": [[string]] }],
@@ -73,7 +66,6 @@ Example of one item:
73
66
  {
74
67
  "rank": 5,
75
68
  "criterion": "Original design assets",
76
- "band": 5,
77
69
  "score": 2,
78
70
  "verdict": "The hero shows the real app, and the six feature icons are Lucide icons.",
79
71
  "records": [{
@@ -61,11 +61,10 @@
61
61
  "judgeResult": {
62
62
  "type": "object",
63
63
  "additionalProperties": false,
64
- "required": ["rank", "criterion", "band", "score", "verdict", "records", "evidence", "notChecked", "fixes"],
64
+ "required": ["rank", "criterion", "score", "verdict", "records", "evidence", "notChecked", "fixes"],
65
65
  "properties": {
66
66
  "rank": { "type": "integer" },
67
67
  "criterion": { "type": "string" },
68
- "band": { "type": "integer", "minimum": 1, "maximum": 10 },
69
68
  "score": { "type": "integer", "minimum": 1, "maximum": 4 },
70
69
  "verdict": { "type": "string" },
71
70
  "records": {