@clearlysh/beehaven 0.8.36 → 0.8.37
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/dist/build-channel.js +2 -2
- package/dist/call-brief.js +157 -0
- package/dist/claude-bridge.js +4 -1
- package/dist/cli.js +5 -1
- package/dist/commands/help.js +3 -1
- package/dist/commands/workspace.js +31 -2
- package/dist/daemon-client.js +7 -2
- package/dist/ipc-client.js +4 -1
- package/dist/ipc-server.js +4 -1
- package/dist/relay.js +9 -5
- package/dist/utf8-stream.js +23 -0
- package/package.json +1 -1
- package/skills/clearly-canvas/SKILL.md +16 -8
- package/skills/clearly-canvas/authoring.md +40 -17
- package/skills/clearly-canvas/review.mjs +25 -4
- package/skills/index.json +1 -1
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = '7674ddaca41b';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-10-
|
|
58
|
+
export const BUILD_TIME = '2026-10-29T21:35Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beehaven call <action> … --brief` — a receipt a person (or an agent's context window) can read.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY THIS EXISTS. A `canvas-act` batch answers with every step's full value plus the lint of
|
|
5
|
+
* every touched artboard — routinely tens of kilobytes. Agents learned to pipe it into a summariser
|
|
6
|
+
* they wrote on the spot (`… 2>&1 | node summ.mjs`), and in the first recorded canvas take (MCP2-20)
|
|
7
|
+
* that improvisation cost four failed tool calls: shell pipes and `/tmp` redirects need approval in
|
|
8
|
+
* a headless agent, and the summariser crashed on the stderr breadcrumb it was fed. The receipt the
|
|
9
|
+
* agent wanted is printed here instead, from the same result, with the exit code unchanged.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ IT DROPS NOTHING THAT DECIDES THE NEXT STEP: failed steps (with their error), rollbacks and
|
|
12
|
+
* skips, lint findings that need fixing, a review's verdict and blocking count. What it leaves out
|
|
13
|
+
* is bulk — per-step echoes, previews, font metadata. `--json` still prints the whole payload.
|
|
14
|
+
*/
|
|
15
|
+
const clip = (s, n) => {
|
|
16
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
18
|
+
};
|
|
19
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
20
|
+
/** `canvas.create-node` → `create-node`; `frame.create` stays as written. */
|
|
21
|
+
const verb = (a) => (a.startsWith('canvas.') ? a.slice(7) : a);
|
|
22
|
+
export function briefReceipt(action, result) {
|
|
23
|
+
const r = (result && typeof result === 'object' ? result : { value: result });
|
|
24
|
+
const error = typeof r.error === 'string' && r.error ? r.error : null;
|
|
25
|
+
/**
|
|
26
|
+
* MCP2-28 (codex review) — A FAILED BATCH IS STILL A BATCH. This returned the one-line refusal for
|
|
27
|
+
* any `ok:false`, before looking at `results`, so a rolled-back canvas-act (step 2 of 2 refused)
|
|
28
|
+
* printed a truncated error with no step number and no "rolled back". The batch receipt is read
|
|
29
|
+
* first; the top-level failure is kept on it, so the exit code still says it failed.
|
|
30
|
+
*/
|
|
31
|
+
if (Array.isArray(r.results) && r.results.length) {
|
|
32
|
+
const b = batchReceipt(action, r);
|
|
33
|
+
if (error || r.ok === false) {
|
|
34
|
+
b.ok = false;
|
|
35
|
+
b.lines[0] = b.lines[0].replace(/^✓/, '✗');
|
|
36
|
+
const why = clip(error ?? r.message ?? r.code ?? '', 200);
|
|
37
|
+
// Not repeated when a failed step already says it (the step line clips at 140, this at 200).
|
|
38
|
+
if (why && !b.lines.some((l) => l.includes(why.slice(0, 60))))
|
|
39
|
+
b.lines.splice(1, 0, ` ${why}`);
|
|
40
|
+
}
|
|
41
|
+
return b;
|
|
42
|
+
}
|
|
43
|
+
if (error || r.ok === false) {
|
|
44
|
+
return { ok: false, lines: [`✗ ${action} · ${clip(error ?? r.message ?? r.code ?? 'refused', 160)}`] };
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(r.results))
|
|
47
|
+
return batchReceipt(action, r);
|
|
48
|
+
return { ok: true, lines: [`✓ ${action}${describeValue(r)}`] };
|
|
49
|
+
}
|
|
50
|
+
function batchReceipt(action, r) {
|
|
51
|
+
const steps = r.results;
|
|
52
|
+
const failed = steps.map((s, i) => ({ s, i })).filter(({ s }) => s?.ok === false);
|
|
53
|
+
const lines = [];
|
|
54
|
+
// What ran: a review or a set of measurements reads better as its result than as a step count.
|
|
55
|
+
const reviews = steps.filter((s) => /canvas\.review/.test(s?.action) && s?.ok !== false);
|
|
56
|
+
const measures = steps.filter((s) => s?.action === 'canvas.measure-text' && s?.ok !== false);
|
|
57
|
+
const counts = new Map();
|
|
58
|
+
for (const s of steps)
|
|
59
|
+
counts.set(verb(s?.action ?? '?'), (counts.get(verb(s?.action ?? '?')) ?? 0) + 1);
|
|
60
|
+
const mix = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([a, n]) => (n > 1 ? `${a} ×${n}` : a)).join(', ');
|
|
61
|
+
let added = 0, changed = 0, removed = 0, sawDelta = false;
|
|
62
|
+
for (const s of steps) {
|
|
63
|
+
const d = s?.value?.canvasChanges;
|
|
64
|
+
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
65
|
+
sawDelta = true;
|
|
66
|
+
added += Number(d.added || 0);
|
|
67
|
+
changed += Number(d.changed || 0);
|
|
68
|
+
removed += Number(d.removed || 0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const rev = r.canvasNodesRev ?? [...steps].reverse().find((s) => s?.value?.canvasNodesRev != null)?.value?.canvasNodesRev;
|
|
72
|
+
const head = [`${failed.length ? '✗' : '✓'} ${action}`, `${plural(steps.length, 'step')}${mix ? ` (${mix})` : ''}`];
|
|
73
|
+
if (sawDelta && added + changed + removed)
|
|
74
|
+
head.push(`+${added} ~${changed} −${removed} layers`);
|
|
75
|
+
if (rev != null)
|
|
76
|
+
head.push(`rev ${rev}`);
|
|
77
|
+
if (r.rolledBack)
|
|
78
|
+
head.push('rolled back');
|
|
79
|
+
if (r.partial)
|
|
80
|
+
head.push('partly applied');
|
|
81
|
+
if (Array.isArray(r.skipped) ? r.skipped.length : r.skipped)
|
|
82
|
+
head.push(`${Array.isArray(r.skipped) ? r.skipped.length : r.skipped} skipped`);
|
|
83
|
+
lines.push(head.join(' · '));
|
|
84
|
+
for (const { s, i } of failed.slice(0, 3)) {
|
|
85
|
+
lines.push(` ✗ step ${i + 1} ${verb(s.action ?? '?')}: ${clip(s.error ?? s.value?.error ?? 'failed', 140)}`);
|
|
86
|
+
}
|
|
87
|
+
if (failed.length > 3)
|
|
88
|
+
lines.push(` … ${failed.length - 3} more failed steps`);
|
|
89
|
+
for (const m of measures.slice(0, 6)) {
|
|
90
|
+
const v = m.value ?? {};
|
|
91
|
+
lines.push(` ${Math.round(v.width ?? 0)}×${Math.round(v.height ?? 0)} · ${plural(v.lineCount ?? 1, 'line')}${v.exact === false ? ' · estimated' : ''} — ${v.family ?? ''} ${v.size ?? ''}/${v.fontWeight ?? ''}`.trimEnd());
|
|
92
|
+
}
|
|
93
|
+
for (const rv of reviews) {
|
|
94
|
+
const v = rv.value ?? {};
|
|
95
|
+
const reports = Array.isArray(v.reviews) ? v.reviews : [v];
|
|
96
|
+
for (const rep of reports) {
|
|
97
|
+
const blocking = rep.blocking?.craft ?? 0;
|
|
98
|
+
const where = rep.scope?.name ?? rep.scope?.ids?.[0] ?? '';
|
|
99
|
+
lines.push(` review${where ? ` ${clip(where, 40)}` : ''}: ${rep.verdict ?? 'unknown'}${blocking ? ` · ${plural(blocking, 'blocking finding')}` : ''}`);
|
|
100
|
+
for (const f of (rep.craft?.findings ?? []).filter((x) => x.severity === 'warn').slice(0, 3)) {
|
|
101
|
+
lines.push(` ${f.check}: ${clip(f.detail, 120)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Lint of the touched artboards: what must be fixed next, never the whole report.
|
|
106
|
+
for (const d of (Array.isArray(r.diagnostics) ? r.diagnostics : [])) {
|
|
107
|
+
const errors = d.errors ?? [];
|
|
108
|
+
if (!errors.length && d.verdict !== 'fix')
|
|
109
|
+
continue;
|
|
110
|
+
lines.push(` lint ${clip(d.artboard ?? d.id, 40)}: ${d.verdict ?? 'fix'}`);
|
|
111
|
+
for (const e of errors.slice(0, 3))
|
|
112
|
+
lines.push(` ${clip(e, 140)}`);
|
|
113
|
+
if (errors.length > 3 || d.moreErrors)
|
|
114
|
+
lines.push(` … ${(errors.length - 3 > 0 ? errors.length - 3 : 0) + (d.moreErrors ?? 0)} more`);
|
|
115
|
+
}
|
|
116
|
+
const clean = (Array.isArray(r.diagnostics) ? r.diagnostics : []).filter((d) => d.verdict && d.verdict !== 'fix');
|
|
117
|
+
if (clean.length)
|
|
118
|
+
lines.push(` lint ${[...new Set(clean.map((d) => d.verdict))].join('/')} on ${plural(clean.length, 'artboard')}`);
|
|
119
|
+
/**
|
|
120
|
+
* MCP2-25 — THE CARRIED TICKET'S OUTCOME DECIDES THE NEXT STEP TOO. A batch carrying `ticket` moves
|
|
121
|
+
* it only when its review passes; the receipt said nothing either way, so an agent could not tell a
|
|
122
|
+
* done commit from one the review held back.
|
|
123
|
+
*/
|
|
124
|
+
const t = r.ticket;
|
|
125
|
+
if (t && typeof t === 'object') {
|
|
126
|
+
if (t.applied)
|
|
127
|
+
lines.push(` ticket ${t.key ?? t.id} → ${t.status ?? 'updated'}${t.frame?.url ? ` · frame ${t.frame.url}` : ''}`);
|
|
128
|
+
else
|
|
129
|
+
lines.push(` ✗ ticket ${t.id ?? ''} not moved: ${clip(t.reason ?? t.error ?? 'refused', 200)}`);
|
|
130
|
+
}
|
|
131
|
+
return { ok: failed.length === 0, lines };
|
|
132
|
+
}
|
|
133
|
+
/** A one-line description of an ordinary result: its identity and what it holds. */
|
|
134
|
+
function describeValue(r) {
|
|
135
|
+
const bits = [];
|
|
136
|
+
const id = r.key ?? r.id ?? r.compositionId ?? r.documentId;
|
|
137
|
+
if (id)
|
|
138
|
+
bits.push(String(id).length > 24 ? String(id).slice(0, 8) : String(id));
|
|
139
|
+
const title = r.title ?? r.name;
|
|
140
|
+
if (typeof title === 'string' && title)
|
|
141
|
+
bits.push(`"${clip(title, 60)}"`);
|
|
142
|
+
if (typeof r.status === 'string')
|
|
143
|
+
bits.push(r.status);
|
|
144
|
+
if (r.verdict)
|
|
145
|
+
bits.push(`verdict ${r.verdict}`);
|
|
146
|
+
for (const [k, v] of Object.entries(r)) {
|
|
147
|
+
if (Array.isArray(v) && v.length && bits.length < 4 && !['results', 'diagnostics'].includes(k))
|
|
148
|
+
bits.push(`${v.length} ${k}`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof r.count === 'number' && !bits.some((b) => b.endsWith(' items')))
|
|
151
|
+
bits.push(`count ${r.count}`);
|
|
152
|
+
if (typeof r.url === 'string')
|
|
153
|
+
bits.push(r.url);
|
|
154
|
+
if (r.value !== undefined && typeof r.value !== 'object')
|
|
155
|
+
bits.push(clip(r.value, 120));
|
|
156
|
+
return bits.length ? ` · ${bits.join(' · ')}` : '';
|
|
157
|
+
}
|
package/dist/claude-bridge.js
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from 'os';
|
|
|
22
22
|
import { EventEmitter } from 'events';
|
|
23
23
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
24
24
|
import { ipcSocketPath, watcherSocketPath, socketMayExist } from './ipc-path.js';
|
|
25
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
25
26
|
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
26
27
|
export class ClaudeBridge extends EventEmitter {
|
|
27
28
|
socket = null;
|
|
@@ -56,8 +57,10 @@ export class ClaudeBridge extends EventEmitter {
|
|
|
56
57
|
this.send({ type: 'session:announce', label: 'claude-code', app: 'bridge' });
|
|
57
58
|
resolve(true);
|
|
58
59
|
});
|
|
60
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
61
|
+
const decode = utf8Stream();
|
|
59
62
|
socket.on('data', (data) => {
|
|
60
|
-
this.buffer += data
|
|
63
|
+
this.buffer += decode(data);
|
|
61
64
|
const lines = this.buffer.split('\n');
|
|
62
65
|
this.buffer = lines.pop() || '';
|
|
63
66
|
for (const line of lines) {
|
package/dist/cli.js
CHANGED
|
@@ -408,7 +408,7 @@ ${DEV_ONLY_HELP ? `
|
|
|
408
408
|
beehaven actions --all …including ones this caller cannot invoke
|
|
409
409
|
beehaven actions --search <q> Find one by substring
|
|
410
410
|
beehaven explain <action> Its exact arguments, from the live schema
|
|
411
|
-
beehaven call <action> [json] Invoke it; returns the raw JSON result
|
|
411
|
+
beehaven call <action> [json] Invoke it; returns the raw JSON result (--brief: a short receipt; --out <file>: the JSON to a file)
|
|
412
412
|
|
|
413
413
|
# discover, then run
|
|
414
414
|
beehaven actions --search ticket
|
|
@@ -1443,6 +1443,8 @@ async function run() {
|
|
|
1443
1443
|
address: { type: 'string' },
|
|
1444
1444
|
protocol: { type: 'string' },
|
|
1445
1445
|
json: { type: 'boolean', default: false },
|
|
1446
|
+
// `beehaven call … --brief`: a short receipt instead of the full JSON (call-brief.ts).
|
|
1447
|
+
brief: { type: 'boolean', default: false },
|
|
1446
1448
|
// `--conversation <id>` threads `beehaven agent "<msg>"` onto an existing
|
|
1447
1449
|
// Omni thread instead of minting a new one per invocation.
|
|
1448
1450
|
conversation: { type: 'string' },
|
|
@@ -2090,6 +2092,8 @@ async function run() {
|
|
|
2090
2092
|
'slides', 'deck',
|
|
2091
2093
|
// `brand tokens -o tokens.css` · `component render -o button.svg`
|
|
2092
2094
|
'brand', 'brands', 'component', 'components',
|
|
2095
|
+
// MCP2-25 — `call <action> … --out result.json`: the whole result to a file, the receipt to the console.
|
|
2096
|
+
'call',
|
|
2093
2097
|
]);
|
|
2094
2098
|
/**
|
|
2095
2099
|
* ⚠ ONE REFUSAL, because there is now one flag. Both spellings are named in the message so a
|
package/dist/commands/help.js
CHANGED
|
@@ -715,7 +715,7 @@ const PAGES = {
|
|
|
715
715
|
related: ['call', 'pwd', 'workspace'],
|
|
716
716
|
},
|
|
717
717
|
call: {
|
|
718
|
-
synopsis: 'beehaven call <action> [json-input] [--building <type>:<id>] [--json]',
|
|
718
|
+
synopsis: 'beehaven call <action> [json-input | @file | -] [--building <type>:<id>] [--json | --brief]',
|
|
719
719
|
description: `Invoke an RPC on the current target. Input is a JSON string.
|
|
720
720
|
Schema comes from the action's manifest — use \`beehaven actions\` first
|
|
721
721
|
to see what's available, or \`beehaven explain <action>\` for the exact
|
|
@@ -725,10 +725,12 @@ const PAGES = {
|
|
|
725
725
|
flags: [
|
|
726
726
|
{ name: '--building <type>:<id>', desc: 'Explicit target (e.g. shopifyStore:clearly-demo)' },
|
|
727
727
|
{ name: '--json', desc: 'Suppress breadcrumb + hint blocks; raw stdout only' },
|
|
728
|
+
{ name: '--brief', desc: 'A short receipt instead of the JSON: steps, layer changes, failures, lint to fix, review verdicts. Same exit code.' },
|
|
728
729
|
{ name: '--timeout <ms>', desc: 'Override the 30s default' },
|
|
729
730
|
],
|
|
730
731
|
examples: [
|
|
731
732
|
{ cmd: 'beehaven call note-create \'{"title":"Hello"}\'' },
|
|
733
|
+
{ cmd: 'beehaven call canvas-act @build.json --brief', note: 'a canvas batch, read as a one-line receipt' },
|
|
732
734
|
{ cmd: "beehaven call project-list '{}' --building home" },
|
|
733
735
|
{
|
|
734
736
|
cmd: 'beehaven call admin-agent-chat \'{"conversationId":"c1","message":"top sellers?","scope":"read"}\'',
|
|
@@ -1132,7 +1132,36 @@ export async function cmdCall(action, rawInput, values) {
|
|
|
1132
1132
|
printCallErrorHints(raw.message, action, values);
|
|
1133
1133
|
process.exit(1);
|
|
1134
1134
|
}
|
|
1135
|
-
|
|
1135
|
+
/**
|
|
1136
|
+
* MCP2-25 — `call … --out <file>` writes the whole JSON result to a file. The canvas skill tells
|
|
1137
|
+
* agents to save a large response once and read a summary, and the only way was `> file`, which a
|
|
1138
|
+
* headless agent needs permission for: a recorded Claude Code take lost a call to "`--out` is not
|
|
1139
|
+
* supported by `beehaven call`". The receipt (or a one-line note) still prints; the exit code is
|
|
1140
|
+
* the result's, exactly as without the flag.
|
|
1141
|
+
*/
|
|
1142
|
+
const outPath = values.out ?? values.output;
|
|
1143
|
+
if (typeof outPath === 'string' && outPath) {
|
|
1144
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
1145
|
+
const { dirname, resolve } = await import('node:path');
|
|
1146
|
+
const file = resolve(outPath);
|
|
1147
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1148
|
+
const text = result && typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
1149
|
+
writeFileSync(file, text + '\n');
|
|
1150
|
+
if (!values.brief)
|
|
1151
|
+
console.log(`wrote ${text.length.toLocaleString()} bytes to ${outPath}`);
|
|
1152
|
+
}
|
|
1153
|
+
let briefFailed = false;
|
|
1154
|
+
if (values.brief && !values.json) {
|
|
1155
|
+
// ⚠ THE SAME RESULT, READ FOR A PERSON — see call-brief.ts for what is kept and why.
|
|
1156
|
+
const { briefReceipt } = await import('../call-brief.js');
|
|
1157
|
+
const receipt = briefReceipt(action, result);
|
|
1158
|
+
console.log(receipt.lines.join('\n'));
|
|
1159
|
+
briefFailed = !receipt.ok;
|
|
1160
|
+
}
|
|
1161
|
+
else if (typeof outPath === 'string' && outPath) {
|
|
1162
|
+
// Written above; the console keeps only the note, so a large result stays out of the context.
|
|
1163
|
+
}
|
|
1164
|
+
else if (result && typeof result === 'object') {
|
|
1136
1165
|
console.log(JSON.stringify(result, null, 2));
|
|
1137
1166
|
}
|
|
1138
1167
|
else {
|
|
@@ -1234,7 +1263,7 @@ export async function cmdCall(action, rawInput, values) {
|
|
|
1234
1263
|
}
|
|
1235
1264
|
printKnownActionAdvice(action, input, result);
|
|
1236
1265
|
}
|
|
1237
|
-
if (isFailureResult(result))
|
|
1266
|
+
if (briefFailed || isFailureResult(result))
|
|
1238
1267
|
process.exit(1);
|
|
1239
1268
|
}
|
|
1240
1269
|
catch (err) {
|
package/dist/daemon-client.js
CHANGED
|
@@ -18,6 +18,7 @@ import net from 'net';
|
|
|
18
18
|
import { resolveClientAuthToken } from './ipc-auth.js';
|
|
19
19
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
20
20
|
import { ipcSocketPath, socketMayExist } from './ipc-path.js';
|
|
21
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
21
22
|
const TIMEOUT_MS = 60_000;
|
|
22
23
|
const AUTH_TIMEOUT_MS = 3_000;
|
|
23
24
|
/** One attempt. Resolves, never rejects. */
|
|
@@ -112,8 +113,10 @@ function handshake(socket) {
|
|
|
112
113
|
socket.removeListener('data', onData);
|
|
113
114
|
reject(new Error('IPC auth handshake timeout'));
|
|
114
115
|
}, AUTH_TIMEOUT_MS);
|
|
116
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
117
|
+
const decode = utf8Stream();
|
|
115
118
|
const onData = (chunk) => {
|
|
116
|
-
buffer += chunk
|
|
119
|
+
buffer += decode(chunk);
|
|
117
120
|
const lines = buffer.split('\n');
|
|
118
121
|
buffer = lines.pop() || '';
|
|
119
122
|
for (const line of lines) {
|
|
@@ -218,8 +221,10 @@ function sendIpc(socket, msg) {
|
|
|
218
221
|
socket.removeListener('data', onData);
|
|
219
222
|
reject(new Error(`Daemon RPC timeout after ${TIMEOUT_MS}ms`));
|
|
220
223
|
}, TIMEOUT_MS);
|
|
224
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
225
|
+
const decode = utf8Stream();
|
|
221
226
|
const onData = (chunk) => {
|
|
222
|
-
buffer += chunk
|
|
227
|
+
buffer += decode(chunk);
|
|
223
228
|
const lines = buffer.split('\n');
|
|
224
229
|
buffer = lines.pop() || '';
|
|
225
230
|
for (const line of lines) {
|
package/dist/ipc-client.js
CHANGED
|
@@ -11,6 +11,7 @@ import { EventEmitter } from 'events';
|
|
|
11
11
|
import { resolveClientAuthToken } from './ipc-auth.js';
|
|
12
12
|
import { ipcSocketPath, socketMayExist } from './ipc-path.js';
|
|
13
13
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
14
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
14
15
|
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
15
16
|
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
16
17
|
/** Default timeout for RPC calls (ms) */
|
|
@@ -77,8 +78,10 @@ export class IpcClient extends EventEmitter {
|
|
|
77
78
|
socket.on('connect', () => {
|
|
78
79
|
this._connected = true;
|
|
79
80
|
});
|
|
81
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
82
|
+
const decode = utf8Stream();
|
|
80
83
|
socket.on('data', (data) => {
|
|
81
|
-
this.buffer += data
|
|
84
|
+
this.buffer += decode(data);
|
|
82
85
|
const lines = this.buffer.split('\n');
|
|
83
86
|
this.buffer = lines.pop() || '';
|
|
84
87
|
for (const line of lines) {
|
package/dist/ipc-server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { createConnection, createServer } from 'net';
|
|
|
27
27
|
import { EventEmitter } from 'events';
|
|
28
28
|
import { resolveAuthScope } from './ipc-auth.js';
|
|
29
29
|
import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
|
|
30
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
30
31
|
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
31
32
|
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
32
33
|
/**
|
|
@@ -194,8 +195,10 @@ export class IpcServer extends EventEmitter {
|
|
|
194
195
|
daemonPid: process.pid,
|
|
195
196
|
requireAuth: true,
|
|
196
197
|
});
|
|
198
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
199
|
+
const decode = utf8Stream();
|
|
197
200
|
socket.on('data', (data) => {
|
|
198
|
-
client.buffer += data
|
|
201
|
+
client.buffer += decode(data);
|
|
199
202
|
const lines = client.buffer.split('\n');
|
|
200
203
|
client.buffer = lines.pop() || '';
|
|
201
204
|
for (const line of lines) {
|
package/dist/relay.js
CHANGED
|
@@ -777,10 +777,14 @@ export class Relay {
|
|
|
777
777
|
this.heartbeatInterval = null;
|
|
778
778
|
this.wsPingInterval = null;
|
|
779
779
|
this.wsWakeDetector = null;
|
|
780
|
-
// Reject pending requests
|
|
781
|
-
|
|
780
|
+
// Reject pending requests.
|
|
781
|
+
// ⚠ CAN2-175 — SAY THE OUTCOME IS UNKNOWN, as the timeout does. A dropped socket loses the REPLY,
|
|
782
|
+
// not the request: a canvas batch carries on and lands on the workspace. A bare "WebSocket closed"
|
|
783
|
+
// read as "nothing happened", the caller resent at once, and could not tell which state was real.
|
|
784
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
782
785
|
clearTimeout(pending.timer);
|
|
783
|
-
pending.reject(new Error(
|
|
786
|
+
pending.reject(new Error(`WebSocket closed (code ${code ?? '?'}) before the reply (action=${pending.action ?? '?'}, requestId=${requestId}); outcome unknown: `
|
|
787
|
+
+ 'the request may still be running on the workspace or may already have landed. Read its state before resending.'));
|
|
784
788
|
}
|
|
785
789
|
this.pendingRequests.clear();
|
|
786
790
|
this.backoff.closed(openedAt);
|
|
@@ -1098,12 +1102,12 @@ export class Relay {
|
|
|
1098
1102
|
return reject(new Error('WebSocket not connected'));
|
|
1099
1103
|
}
|
|
1100
1104
|
const requestId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1105
|
+
const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
|
|
1101
1106
|
const timer = setTimeout(() => {
|
|
1102
1107
|
this.pendingRequests.delete(requestId);
|
|
1103
|
-
const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
|
|
1104
1108
|
reject(new Error(`Request timeout after ${timeout}ms (action=${action}, requestId=${requestId}); outcome unknown`));
|
|
1105
1109
|
}, timeout);
|
|
1106
|
-
this.pendingRequests.set(requestId, { resolve, reject, timer });
|
|
1110
|
+
this.pendingRequests.set(requestId, { resolve, reject, timer, action });
|
|
1107
1111
|
// Send both rid (legacy) and requestId (workspace actions) for compatibility
|
|
1108
1112
|
if (!this.wsSend({ ...msg, rid: requestId, requestId })) {
|
|
1109
1113
|
clearTimeout(timer);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utf8-stream — decode a socket's bytes ONCE PER CHARACTER, not once per chunk (CA-63).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY THIS EXISTS (staging, 2026-10-23). A 402-step `beehaven call canvas-act @build.json` (90,714
|
|
5
|
+
* bytes, the file holding the correct C2 B7) stored ONE text node's middle dot as "��" — the
|
|
6
|
+
* same node, the same character, on every resend — while dozens of identical dots elsewhere in the batch
|
|
7
|
+
* were stored intact. Every IPC reader did `buffer += chunk.toString()`. A local socket hands the reader
|
|
8
|
+
* the stream in pieces (8 KB on macOS, 64 KB on Linux), so any multi-byte character that straddles a
|
|
9
|
+
* piece boundary was decoded as two halves, each half an invalid sequence, each a U+FFFD. Which character
|
|
10
|
+
* lands on a boundary depends only on the bytes before it, so the same payload broke the same character
|
|
11
|
+
* every time. The CLI → daemon direction corrupted what was STORED; the daemon → CLI direction corrupted
|
|
12
|
+
* what a reader was SHOWN (a long scene read could carry U+FFFD the canvas does not have).
|
|
13
|
+
*
|
|
14
|
+
* A `StringDecoder` holds an incomplete trailing sequence until its remaining bytes arrive, so the text is
|
|
15
|
+
* exactly what one decode of the whole stream would give. One decoder per stream (it carries state), and
|
|
16
|
+
* never shared between two sockets.
|
|
17
|
+
*/
|
|
18
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
19
|
+
/** A stateful UTF-8 decoder for ONE byte stream. A string chunk (a socket with an encoding set) passes through. */
|
|
20
|
+
export function utf8Stream() {
|
|
21
|
+
const decoder = new StringDecoder('utf8');
|
|
22
|
+
return (chunk) => (typeof chunk === 'string' ? chunk : decoder.write(chunk));
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -32,7 +32,7 @@ For a new composition or substantial redesign, use the workflow below.
|
|
|
32
32
|
|
|
33
33
|
Keep the requested scope. A landing concept with a logo needs its page layouts and a usable
|
|
34
34
|
logo; add application boards or a full guideline system only when the brief calls for them.
|
|
35
|
-
Read only relevant references. Save large tool responses once and print compact summaries.
|
|
35
|
+
Read only relevant references. Save large tool responses once (`beehaven call … --out file.json`) and print compact summaries.
|
|
36
36
|
|
|
37
37
|
## The studio bar — what "done" means, every time
|
|
38
38
|
|
|
@@ -46,7 +46,8 @@ A composition is done when every requested artboard meets all five conditions:
|
|
|
46
46
|
remains: text colliding or covered, contrast under WCAG (4.5:1 body, 3:1 display),
|
|
47
47
|
type below the artboard's legible floor, missing media, text overflowing its box,
|
|
48
48
|
placeholder copy. Fix it. For an intentional finding, use
|
|
49
|
-
`accept:[{check,ids:[node IDs],reason}]
|
|
49
|
+
`accept:[{check,ids:[node IDs],reason}]`; one rule may name every layer with the same
|
|
50
|
+
deliberate finding. Accepted findings stay visible and return `look`.
|
|
50
51
|
Legacy `ignore` can suppress multiple checks and is not an unconditional pass.
|
|
51
52
|
2. **Every `look` finding was looked at**: type-size and palette sprawl, near-miss
|
|
52
53
|
alignment, copy against the trim, text over imagery. Act on it or say why not.
|
|
@@ -117,8 +118,9 @@ Read `clearly-agent` before your first workspace call. Pin the intended workspac
|
|
|
117
118
|
sign in once. Do not switch a user's explicit workspace to `home`.
|
|
118
119
|
|
|
119
120
|
Your surface: the shell. The `beehaven` examples in this skill are literal commands to
|
|
120
|
-
run; they are not shapes to translate into an MCP canvas-action tool.
|
|
121
|
-
|
|
121
|
+
run; they are not shapes to translate into an MCP canvas-action tool. The Clearly connector
|
|
122
|
+
has its own canvas tools for agents without a shell (batched per kind of step, see
|
|
123
|
+
authoring.md); in a shell the CLI's mixed `canvas-act` batch takes fewer calls.
|
|
122
124
|
|
|
123
125
|
```sh
|
|
124
126
|
beehaven env
|
|
@@ -156,7 +158,7 @@ builds on the last and can be reviewed alone. Build ONE commit per canvas-act ba
|
|
|
156
158
|
`canvas.review` of the artboard and carries
|
|
157
159
|
`"ticket":{"id":"<key>","status":"done","resolution":"<what changed>"}` — it lands only if that
|
|
158
160
|
review is clean or look; `result.ticket` says whether it did, and the ticket logs the canvas
|
|
159
|
-
revision
|
|
161
|
+
revision, the layers the commit changed and a link to its frame. Auto-layout, gradients, shadows, drawing and placeholders:
|
|
160
162
|
see "Technique" in [authoring.md](authoring.md).
|
|
161
163
|
|
|
162
164
|
`composition-board-get {compositionId}` reads the plan back. `design-start` with a project, ticket or
|
|
@@ -279,7 +281,7 @@ a detail view catches collisions, wrapping, cropping and legibility.
|
|
|
279
281
|
The bundled helper saves pixels directly to disk so base64 does not fill your context:
|
|
280
282
|
|
|
281
283
|
```sh
|
|
282
|
-
node <skill-directory>/review.mjs <composition-id> <frame-id-or-unique-name>
|
|
284
|
+
node <skill-directory>/review.mjs <composition-id> <frame-id-or-unique-name> canvas-review/01
|
|
283
285
|
# Open the returned PNG using your image-viewing tool; read the JSON findings.
|
|
284
286
|
```
|
|
285
287
|
|
|
@@ -300,9 +302,15 @@ The helper prints the verdict and every finding. Work until the verdict is `clea
|
|
|
300
302
|
use a real frame ID with `--perceive` for a preview-only check and run the text measurement
|
|
301
303
|
and audit separately. A failed or missing render means visual verification is incomplete.
|
|
302
304
|
|
|
305
|
+
A deliberate finding (say why in the handoff): write the findings you are keeping to a file and
|
|
306
|
+
pass it to the same helper. They stay visible in the report and return `look`.
|
|
307
|
+
|
|
308
|
+
```json
|
|
309
|
+
[{"check":"text-over-image","ids":["<text-id>","<image-id>"],"reason":"Intentional type over this photograph; rendered contrast was inspected."}]
|
|
310
|
+
```
|
|
311
|
+
|
|
303
312
|
```sh
|
|
304
|
-
|
|
305
|
-
beehaven call canvas-act '{"compositionId":"<id>","action":"canvas.review","args":{"ids":["<frame>"],"accept":[{"check":"text-over-image","ids":["<text-id>","<image-id>"],"reason":"Intentional type over this photograph; rendered contrast was inspected."}]}}'
|
|
313
|
+
node <skill-directory>/review.mjs <composition-id> <frame> canvas-review/02 --accept accepted.json
|
|
306
314
|
```
|
|
307
315
|
|
|
308
316
|
Asset storage is a reference-aware pipeline: an SVG may have `svgRef` instead of inline markup.
|
|
@@ -38,15 +38,34 @@ add('canvas.update-nodes', { ids: ['opening-title', 'opening-deck'], patch: { la
|
|
|
38
38
|
add('canvas.auto-layout', { id: 'hero-copy', direction: 'v', gap: 24, sizingH: 'fixed', sizingV: 'hug' });
|
|
39
39
|
add('canvas.auto-layout', { id: '01-opening', direction: 'v', gap: 64, padding: grid.margin, sizingH: 'fixed', sizingV: 'hug' });
|
|
40
40
|
// Continue with the brief's central visual, supporting evidence and footer.
|
|
41
|
-
writeFileSync('
|
|
41
|
+
writeFileSync('canvas-build.json', JSON.stringify({ compositionId, batch }));
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
```sh
|
|
45
45
|
beehaven call composition-create '{"title":"Project — concept","projectId":"<project-id>"}'
|
|
46
46
|
node build.mjs <returned-id>
|
|
47
|
-
beehaven call canvas-act
|
|
47
|
+
beehaven call canvas-act @canvas-build.json --brief
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
`--brief` prints a short receipt instead of the full JSON:
|
|
51
|
+
- step counts by kind;
|
|
52
|
+
- layers added, changed and removed;
|
|
53
|
+
- every failed step with its error;
|
|
54
|
+
- rollbacks and skips;
|
|
55
|
+
- lint to fix and review verdicts.
|
|
56
|
+
|
|
57
|
+
The exit code stays the same. Use it for routine batches instead of piping the JSON through a
|
|
58
|
+
summariser of your own. Drop it when you need a step's full value.
|
|
59
|
+
|
|
60
|
+
Keep builders, payloads and results in your **working directory**:
|
|
61
|
+
- Write files with your file-writing tool, not shell heredocs or `>` redirects into `/tmp`.
|
|
62
|
+
- Run each `beehaven` command on its own rather than chained with `&&`, `;`, `| tee` or `| head`.
|
|
63
|
+
- Save a large result with `beehaven call <action> '…' --out scene.json` (add `--brief` for the
|
|
64
|
+
receipt), not `> file`: the whole JSON goes to the file and only a line reaches the console.
|
|
65
|
+
|
|
66
|
+
Agent hosts that gate shell commands (Claude Code, Codex) refuse heredocs containing JSON and
|
|
67
|
+
compound pipelines outright. One recorded run lost four tool calls that way (MCP2-20).
|
|
68
|
+
|
|
50
69
|
Check `ok`, each `results[].ok`, `count`, `skipped` and `rolledBack`. A failed headless
|
|
51
70
|
batch may roll back its scene writes. Re-read the scene before deciding what to retry;
|
|
52
71
|
do not blindly resend only skipped operations. Mixed live/headless actions may have
|
|
@@ -155,6 +174,7 @@ The same rules the in-app assistant works to (canvas-catalog returns them as `te
|
|
|
155
174
|
- COMPUTED GEOMETRY: shapes from math (radial petals, spirals, grids) are computed and drawn in ONE call: canvas-act { compositionId, script } runs JavaScript with geo.petal/circle/arc/star/polygon/spiral/smooth/rotate/radial/grid/bounds (degrees, 0 = 3 o'clock, clockwise) and a read-only scene list, then applies the { action, args } steps it returns. Never copy numbers from one tool result into another call. A new top-level artboard goes beside or below what scene already holds (or leave out both x and y), never at 0,0 over an existing one.
|
|
156
175
|
- SHEETS: several concepts or variants go on canvas.sheet { name, title, subtitle, cards or count, cols, cardW, cardH }: a live wrap grid of card frames that own their captions, so rows never overlap. Draw into a card with parentId = its id.
|
|
157
176
|
- MEASURE: answer spacing, overlap and alignment questions with canvas.measure { frame } or { ids } and quote its numbers, never from memory or a preview.
|
|
177
|
+
- REFERENCES: canvas.link {id:<exact frame/item id>} returns a stable URL and Markdown for tickets, documents, comments and inbox refs. Use the returned link instead of a screenshot or a guessed canvas URL. It preserves the parent canvas permissions; rename/move retains the layer id.
|
|
158
178
|
- PLACEHOLDERS: until real imagery exists, draw a native placeholder at the final size in a muted brand tone, never a flat accent block: a rect with the slot radius plus a simple motif (an ellipse sun, a vector.create mountain), grouped with canvas.group and named "<section> image placeholder", with any label in its own text node.
|
|
159
179
|
- SECTIONS: one frame per page section, named exactly as its ticket says, inside the page artboard. Build a section in one canvas-act batch that ends with canvas.review of the artboard and carries ticket: { id: "<its key>", status: "done", resolution: "<what changed>" }. The ticket moves only when that review is clean or look; result.ticket says whether it did. Then start the next section.
|
|
160
180
|
|
|
@@ -215,15 +235,13 @@ the backing shows through as dark wedges at the corners.
|
|
|
215
235
|
|
|
216
236
|
### Images from MCP
|
|
217
237
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
`
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
existing image's `src`, `fit` or geometry, but cannot create a node. For a local file,
|
|
226
|
-
use the CLI upload path first; an MCP-only client needs a URL it can already access.
|
|
238
|
+
A connector-only agent places an image already reachable by an absolute URL (or a
|
|
239
|
+
`data:` URI) as an `image` node in `clearly_canvas_add_nodes`:
|
|
240
|
+
`{id,type:"image",name,src,x,y,w,h,fit:"contain",parentId?}`. Use PNG or JPEG: the headless
|
|
241
|
+
review cannot draw WebP yet and does not say so (CAN2-188), so a WebP image looks blank in the
|
|
242
|
+
review PNG while the editor shows it. `clearly_canvas_update_nodes`
|
|
243
|
+
changes an existing image's `src`, `fit` or geometry. For a local file in a shell, use the
|
|
244
|
+
CLI upload path first.
|
|
227
245
|
|
|
228
246
|
Current headless rendering can resolve images, including workspace media. A blank image
|
|
229
247
|
can indicate fetch, decoding or budget failure; check its source and inspect a live view.
|
|
@@ -236,8 +254,8 @@ design. Verify the returned artifact in a browser at realistic viewport sizes.
|
|
|
236
254
|
## Review and scoped perception
|
|
237
255
|
|
|
238
256
|
```sh
|
|
239
|
-
beehaven call canvas-act '{"compositionId":"<id>","action":"canvas.review","args":{"ids":["<frame-id>"]}}'
|
|
240
|
-
beehaven call canvas-perceive '{"compositionId":"<id>","includePixels":true,"fitNodeIds":["<frame-id>"],"pixelScale":1}' >
|
|
257
|
+
beehaven call canvas-act '{"compositionId":"<id>","action":"canvas.review","args":{"ids":["<frame-id>"]}}' --brief
|
|
258
|
+
beehaven call canvas-perceive '{"compositionId":"<id>","includePixels":true,"fitNodeIds":["<frame-id>"],"pixelScale":1}' > perceive.json
|
|
241
259
|
```
|
|
242
260
|
|
|
243
261
|
Use the bundled `review.mjs` to decode the PNG and keep base64 out of the conversation.
|
|
@@ -268,7 +286,12 @@ MCP's artifact tools are split by operation:
|
|
|
268
286
|
node array — pass `expectedRev` from the read.
|
|
269
287
|
- `clearly_edit` patches existing nodes with `changes:{ids,patch}`.
|
|
270
288
|
|
|
271
|
-
|
|
272
|
-
`
|
|
273
|
-
|
|
274
|
-
|
|
289
|
+
The connector also has native canvas tools for agents without a shell, batched per kind of
|
|
290
|
+
step rather than mixed (MCP2-22): `clearly_canvas_add_nodes` builds a section per call (up to
|
|
291
|
+
300 frames, shapes, text, pen paths and groups of its own new layers),
|
|
292
|
+
`clearly_canvas_update_nodes {edits:[{ids,patch}]}` revises many layers at once,
|
|
293
|
+
`clearly_canvas_measure_text {texts:[…]}` measures several lines, and
|
|
294
|
+
`clearly_canvas_review` returns the saved artboard as an MCP image to inspect. There is no
|
|
295
|
+
`clearly_canvas_act`: a mixed batch is the CLI's `canvas-act`. In a shell, use the CLI; it
|
|
296
|
+
is fewer calls for the same scene. Do not claim the design was visually checked from scene
|
|
297
|
+
JSON alone.
|
|
@@ -123,14 +123,35 @@ export async function saveReviewPixels(report, prefix, fetchImage = fetch) {
|
|
|
123
123
|
return saveReview({ ...report, preview: { ...report.preview, dataUrl: `data:image/png;base64,${bytes.toString('base64')}` } }, prefix);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* `--accept <findings.json>`: findings you have looked at and are keeping, each
|
|
128
|
+
* `{ check, ids: [node IDs], reason }`. They stay visible in the report and return `look`.
|
|
129
|
+
* Without this, a deliberate overlap (steam rising out of a bowl) could only be accepted by
|
|
130
|
+
* hand-writing the canvas.review call — agents did exactly that (MCP2-20).
|
|
131
|
+
*/
|
|
132
|
+
export function takeAccept(argv) {
|
|
133
|
+
const i = argv.indexOf('--accept');
|
|
134
|
+
if (i < 0) return { argv, accept: undefined };
|
|
135
|
+
const file = argv[i + 1];
|
|
136
|
+
if (!file || file.startsWith('--')) throw new Error('--accept needs a JSON file: [{"check":"text-over-image","ids":["…"],"reason":"…"}]');
|
|
137
|
+
const accept = JSON.parse(readFileSync(file, 'utf8'));
|
|
138
|
+
if (!Array.isArray(accept) || !accept.every(a => a && typeof a.check === 'string' && Array.isArray(a.ids) && a.ids.length && typeof a.reason === 'string' && a.reason.trim())) {
|
|
139
|
+
throw new Error('--accept file must be an array of { check, ids: [node IDs], reason } with a non-empty reason.');
|
|
140
|
+
}
|
|
141
|
+
return { argv: argv.filter((_, j) => j !== i && j !== i + 1), accept };
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
export async function main(argv = process.argv.slice(2)) {
|
|
145
|
+
const taken = takeAccept(argv);
|
|
146
|
+
argv = taken.argv;
|
|
147
|
+
const accept = taken.accept ? { accept: taken.accept } : {};
|
|
127
148
|
if (argv.includes('--many')) {
|
|
128
149
|
const args = argv.filter(a => a !== '--many');
|
|
129
|
-
if (args.length !== 3) throw new Error('Usage: node review.mjs --many <compositionId> <artboard-ids.json> <output-directory>');
|
|
150
|
+
if (args.length !== 3) throw new Error('Usage: node review.mjs --many <compositionId> <artboard-ids.json> <output-directory> [--accept findings.json]');
|
|
130
151
|
const [compositionId, manifest, directory] = args;
|
|
131
152
|
const ids = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
132
153
|
if (!Array.isArray(ids) || !ids.length) throw new Error('The manifest must be a non-empty artboard ID/name array.');
|
|
133
|
-
const response = rpc('canvas-act', { compositionId, action: 'canvas.review-many', args: { ids, preview: 'files' } });
|
|
154
|
+
const response = rpc('canvas-act', { compositionId, action: 'canvas.review-many', args: { ids, preview: 'files', ...accept } });
|
|
134
155
|
const reports = response.results?.[0]?.value?.reviews;
|
|
135
156
|
if (!Array.isArray(reports) || reports.length !== ids.length) throw new Error('Incomplete review-many result; do not claim all artboards passed.');
|
|
136
157
|
const saved = [];
|
|
@@ -140,7 +161,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
140
161
|
}
|
|
141
162
|
const legacy = argv.includes('--perceive');
|
|
142
163
|
const args = argv.filter((a) => a !== '--perceive');
|
|
143
|
-
if (args.length !== 3) throw new Error('Usage: node review.mjs <compositionId> <frame-id-or-name> <output-prefix> [--perceive]\n--perceive: older workers, preview only; pass a frame ID.');
|
|
164
|
+
if (args.length !== 3) throw new Error('Usage: node review.mjs <compositionId> <frame-id-or-name> <output-prefix> [--accept findings.json] [--perceive]\n--perceive: older workers, preview only; pass a frame ID.');
|
|
144
165
|
const [compositionId, id, prefix] = args;
|
|
145
166
|
let report;
|
|
146
167
|
if (legacy) {
|
|
@@ -150,7 +171,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
150
171
|
report = { preview: response.room?.pixels, visualReviewRequired: true,
|
|
151
172
|
limitations: ['Preview-only compatibility mode. Text metrics and scoped audit were not run.'] };
|
|
152
173
|
} else {
|
|
153
|
-
const response = rpc('canvas-act', { compositionId, action: 'canvas.review', args: { ids: [id], pixelScale: 1 } });
|
|
174
|
+
const response = rpc('canvas-act', { compositionId, action: 'canvas.review', args: { ids: [id], pixelScale: 1, ...accept } });
|
|
154
175
|
report = response.results?.[0]?.value;
|
|
155
176
|
if (!report?.scope) throw new Error('canvas.review unavailable. Check canvas-catalog; use --perceive for an explicitly preview-only check on an older worker.');
|
|
156
177
|
}
|