Skip to content

Commit f4e619b

Browse files
binary-knightclaude
andcommitted
A command line, so another agent can convene the council
bin/overrule.mjs drives a running server over its HTTP API: ask, review, watch, show, list, playbooks. It waits for the verdict, prints the report, and carries the result in its exit status (0 approved, 2 objections, 3 checks failed, 4 incomplete), with --json for a calling agent. Defaults suit unattended use: every ready member, read-only workspace, warnings accepted only with --acknowledge, full access refused without its own flag. A new report template reviews the change in progress, which is what a coding agent wants after making an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4d28057 commit f4e619b

7 files changed

Lines changed: 371 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ Research sends your brief's subject matter to each provider's search service. De
7777

7878
Drop files on the brief or press **Attach files**, from any paired device. The host reads each file's text once and shows how many characters it found. Members get that text in every prompt, labelled as material to evaluate and never as instructions. PDF, Word, Excel, PowerPoint, OpenDocument, old Office formats, plain text, code, and zip archives are read; images are not. Archives are read in memory with path, size, and nesting guards, and nothing inside them is executed or written under a name the archive chose. Uploaded files are deleted when the meeting closes; the extracted text stays until you remove it or the meeting ages out of the history, so the meeting can be reconvened.
7979

80+
## From the command line
81+
82+
Another agent can convene the council while the server runs. `bin/overrule.mjs` drives the same server, so the meeting appears in the browser, uses the same members and limits, and lands in the same history.
83+
84+
```sh
85+
node bin/overrule.mjs review . --playbook changes --cycles 1
86+
node bin/overrule.mjs ask "Is this migration plan sound?" --deep-research --json
87+
node bin/overrule.mjs list
88+
```
89+
90+
`review` attaches a folder read-only and takes a report template; `ask` holds a plain meeting. Both wait for the verdict, print the report on standard output, and set the exit status: **0** approved, **2** objections remain, **3** checks failed, **4** verification incomplete, **1** error. With `--json` the output is one object with the verdict, the final text, and the meeting id, which is what an agent should read. `--no-wait` prints the id instead of waiting, and `watch` or `show` picks it up later. `--attach` sends a file, `--members` and `--drafter` pick the council, and `--level workspace-write` lets the drafter implement rather than only report.
91+
92+
A coding agent can therefore finish a change and hand it straight to the council:
93+
94+
```sh
95+
node bin/overrule.mjs review . --playbook changes --json || echo "the council objected"
96+
```
97+
98+
`npm link` puts it on the path as `overrule`. `OVERRULE_URL` points it at another machine, and `--code` pairs with that machine's pairing code.
99+
80100
## Reviewing a repository
81101

82102
Paste a repository address in the Workspace section and press **Clone and attach**. The clone is shallow, lands in `~/overrule-repos` (set `OVERRULE_REPO_DIR` to move it), and is attached read-only. Cloning the same repository again refreshes that checkout in place. Only `https` addresses are accepted, so nothing reaches for this machine's ssh keys or a local path, and git is never allowed to ask for a password: a private repository fails quickly unless git is already signed in on the host.

‎bin/overrule.mjs‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env node
2+
// A command line into a running Overrule server, so another agent can convene the council and read its verdict.
3+
// Every meeting started this way is an ordinary meeting: same members, same limits, same history, visible in the browser.
4+
import { readFile } from 'node:fs/promises';
5+
import { basename, resolve } from 'node:path';
6+
import { buildPlaybook, PLAYBOOKS } from '../public/playbooks.js';
7+
8+
const DEFAULT_URL = process.env.OVERRULE_URL || 'http://127.0.0.1:4310';
9+
const USAGE = `overrule — convene a council of models from the command line.
10+
11+
overrule ask "<brief>" [options] Hold a meeting about a question.
12+
overrule review [path] [options] Review a workspace, usually with a report template.
13+
overrule watch <id> Follow a meeting that is already running.
14+
overrule show <id> [--json] Print a finished meeting's report.
15+
overrule list [--json] List recent meetings.
16+
overrule playbooks List the report templates.
17+
18+
Options
19+
--playbook <id> Start from a report template (see: overrule playbooks).
20+
--path <dir> Workspace to attach. "review" takes it as its first argument.
21+
--level <name> talk, read-only (default with a workspace), workspace-write, full-access.
22+
--members <names> Comma-separated connection names. Default: every ready connection, up to eight.
23+
--drafter <name> Which member writes the candidate. Default: the first member.
24+
--cycles <n> Turns per member, 1 to 8. Default 2.
25+
--revisions <n> Revisions allowed after objections, 0 to 5. Default 1.
26+
--research Members that can browse may search the web.
27+
--deep-research They research the subject before taking a position. Implies --research.
28+
--attach <file> Attach a document. Repeatable.
29+
--check <command> A command run on the candidate commit at a write level. Repeatable.
30+
--network Allow the network for the implementer and the checks at a write level.
31+
--implement-limit <s> Seconds the implementer may take at a write level.
32+
--turn-limit <s> Seconds one member's turn may take. Default: the server's.
33+
--time-limit <m> Minutes the whole session may take.
34+
--call-limit <n> Model calls the whole session may make.
35+
--acknowledge Accept the warnings this meeting needs (secrets in the tree, uncommitted work).
36+
--acknowledge-full-access Required for --level full-access.
37+
--no-wait Print the meeting id and exit instead of waiting.
38+
--json Print one JSON object instead of a report.
39+
--quiet Print the report only, with no progress.
40+
--url <address> Server to use. Default ${DEFAULT_URL}.
41+
--code <pairing code> Pair first; needed only when the server is not on this machine.
42+
43+
Exit status: 0 approved, 2 objections remain, 3 checks failed, 4 verification incomplete, 1 error.`;
44+
45+
const VERDICT_EXIT = { approved: 0, objections: 2, 'checks-failed': 3, incomplete: 4 };
46+
const FLAGS = new Set(['research', 'deep-research', 'network', 'json', 'quiet', 'no-wait', 'acknowledge', 'acknowledge-full-access', 'help', 'version']);
47+
const MANY = new Set(['attach', 'check']);
48+
49+
export function parseArgs(argv) {
50+
const options = { _: [], attach: [], check: [] };
51+
for (let i = 0; i < argv.length; i++) {
52+
const arg = argv[i];
53+
if (!arg.startsWith('--')) { options._.push(arg); continue; }
54+
const [name, inline] = arg.slice(2).split(/=(.*)/s);
55+
if (FLAGS.has(name)) { options[name] = inline === undefined ? true : inline !== 'false'; continue; }
56+
const value = inline === undefined ? argv[++i] : inline;
57+
if (value === undefined) throw new Error(`--${name} needs a value.`);
58+
if (MANY.has(name)) options[name].push(value); else options[name] = value;
59+
}
60+
return options;
61+
}
62+
63+
const fail = message => { throw Object.assign(new Error(message), { expected: true }); };
64+
const number = (value, name) => { const n = Number(value); if (!Number.isInteger(n)) fail(`--${name} takes a whole number.`); return n; };
65+
66+
class Client {
67+
constructor(url, code) { this.url = url.replace(/\/$/, ''); this.code = code; this.cookie = ''; this.token = ''; }
68+
async call(path, method = 'GET', body, headers = {}) {
69+
let response;
70+
const send = () => fetch(this.url + path, {
71+
method, redirect: 'error',
72+
headers: { ...(body !== undefined && !(body instanceof Uint8Array) ? { 'Content-Type': 'application/json' } : {}), ...(this.token ? { 'X-Mesh-Token': this.token } : {}), ...(this.cookie ? { Cookie: this.cookie } : {}), ...headers },
73+
body: body === undefined ? undefined : body instanceof Uint8Array ? body : JSON.stringify(body),
74+
});
75+
try { response = await send(); }
76+
catch (error) { fail(`No Overrule server at ${this.url}. Start it with "npm start" on that machine, or pass --url. (${error.message})`); }
77+
if (response.status === 401) fail(`${this.url} needs pairing. Pass --code with the pairing code from its LAN access dialog.`);
78+
const text = await response.text();
79+
let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
80+
if (response.status === 403 && data.code === 'stale-token') { await this.connect(); return this.call(path, method, body, headers); }
81+
if (!response.ok) fail(data.error || `${method} ${path} failed with HTTP ${response.status}.`);
82+
return data;
83+
}
84+
async connect() {
85+
if (this.code) {
86+
const response = await fetch(this.url + '/api/unlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: this.code }) }).catch(error => fail(`No Overrule server at ${this.url}. (${error.message})`));
87+
const cookie = response.headers.get('set-cookie');
88+
if (!response.ok) fail('The pairing code was not accepted.');
89+
if (cookie) this.cookie = cookie.split(';')[0];
90+
}
91+
const bootstrap = await this.call('/api/bootstrap');
92+
this.token = bootstrap.token;
93+
return bootstrap;
94+
}
95+
}
96+
97+
// Members: the names the owner gave their connections, or everything that is ready, which is what an agent usually wants.
98+
function chooseMembers(bootstrap, options) {
99+
const ready = bootstrap.providers.filter(p => {
100+
const cli = bootstrap.types[p.type]?.cli;
101+
if (!cli) return p.hasKey || p.environmentKey || p.type === 'compatible';
102+
return bootstrap.clis[p.type === 'codex-cli' ? 'codex' : 'claude']?.installed !== false;
103+
});
104+
let chosen = ready;
105+
if (options.members) {
106+
const wanted = options.members.split(',').map(name => name.trim().toLowerCase()).filter(Boolean);
107+
chosen = wanted.map(name => {
108+
const found = bootstrap.providers.find(p => p.name.toLowerCase() === name || p.id === name);
109+
if (!found) fail(`No connection named "${name}". Available: ${bootstrap.providers.map(p => p.name).join(', ') || 'none'}.`);
110+
return found;
111+
});
112+
}
113+
if (chosen.length < 2) fail(`A meeting needs at least two members; ${chosen.length} are ready. Configure connections in the browser first.`);
114+
chosen = chosen.slice(0, 8);
115+
const drafter = options.drafter ? chosen.find(p => p.name.toLowerCase() === options.drafter.toLowerCase() || p.id === options.drafter) : chosen[0];
116+
if (!drafter) fail(`The drafter "${options.drafter}" is not one of the members.`);
117+
return { participantIds: chosen.map(p => p.id), drafterId: drafter.id, names: chosen.map(p => p.name) };
118+
}
119+
120+
async function attachDocuments(client, files, log) {
121+
const ids = [];
122+
for (const file of files) {
123+
const path = resolve(file);
124+
const data = await readFile(path).catch(() => fail(`Cannot read ${path}.`));
125+
log(`Attaching ${basename(path)} (${data.length.toLocaleString()} bytes)`);
126+
const meta = await client.call('/api/attachments', 'POST', new Uint8Array(data), { 'Content-Type': 'application/octet-stream', 'X-File-Name': encodeURIComponent(basename(path)) });
127+
log(` read ${meta.chars.toLocaleString()} characters${meta.warning ? `: ${meta.warning}` : ''}`);
128+
ids.push(meta.id);
129+
}
130+
return ids;
131+
}
132+
133+
function workspacePayload(options) {
134+
const path = options.path ? resolve(options.path) : null;
135+
if (!path) return undefined;
136+
const level = options.level && options.level !== 'talk' ? options.level : options.level === 'talk' ? null : 'read-only';
137+
if (!level) return undefined;
138+
if (level === 'full-access' && !options['acknowledge-full-access']) fail('Full access needs --acknowledge-full-access: every member with tools can do anything your account can do on that machine.');
139+
return {
140+
path, level, checks: options.check, network: options.network === true || level === 'full-access',
141+
acknowledgeSecrets: Boolean(options.acknowledge), acknowledgeDirty: Boolean(options.acknowledge), acknowledgeFullAccess: Boolean(options['acknowledge-full-access']),
142+
...(options['implement-limit'] ? { implementTimeout: Number(options['implement-limit']) } : {}),
143+
};
144+
}
145+
146+
async function waitFor(client, id, log) {
147+
let last = '';
148+
for (;;) {
149+
const run = await client.call(`/api/runs/${id}`);
150+
if (run.status !== 'running') return run;
151+
const speaking = run.entries.filter(e => e.status === 'running').map(e => e.name).join(', ');
152+
const line = `${run.phase}${speaking ? `: ${speaking}` : ''} (${run.entries.filter(e => e.status === 'complete').length} contributions)`;
153+
if (line !== last) { log(line); last = line; }
154+
await new Promise(resolve => setTimeout(resolve, 2000));
155+
}
156+
}
157+
158+
function report(run) {
159+
const verdict = run.record?.verdict;
160+
return [
161+
`# ${verdict?.label || run.status}${run.workspace ? ` — ${run.workspace.name}` : ''}`,
162+
verdict?.verification || '',
163+
run.workspace?.origin ? `Reviewed ${run.workspace.origin} at ${(run.workspace.head || '').slice(0, 12)}` : '',
164+
'', run.final || run.error || 'No answer.',
165+
].filter(line => line !== '').join('\n');
166+
}
167+
168+
export async function main(argv, { out = console.log, err = console.error } = {}) {
169+
const options = parseArgs(argv);
170+
if (options.help || !options._.length) { out(USAGE); return 0; }
171+
const command = options._[0];
172+
if (command === 'playbooks') { for (const entry of PLAYBOOKS) out(`${entry.id.padEnd(14)} ${entry.label}\n${''.padEnd(15)}${entry.summary}`); return 0; }
173+
174+
const client = new Client(options.url || DEFAULT_URL, options.code);
175+
const log = options.quiet || options.json ? () => {} : message => err(message);
176+
177+
if (command === 'list') {
178+
await client.connect();
179+
const runs = await client.call('/api/runs');
180+
if (options.json) { out(JSON.stringify(runs, null, 2)); return 0; }
181+
for (const run of runs.slice(0, 20)) out(`${run.id.slice(0, 8)} ${String(run.status).padEnd(11)} ${new Date(run.createdAt).toLocaleString()} ${(run.prompt || '').replace(/\s+/g, ' ').slice(0, 70)}`);
182+
return 0;
183+
}
184+
if (command === 'show' || command === 'watch') {
185+
const id = options._[1] || fail(`overrule ${command} needs a meeting id.`);
186+
await client.connect();
187+
const found = (await client.call('/api/runs')).find(run => run.id === id || run.id.startsWith(id)) || fail(`No meeting starts with ${id}.`);
188+
const run = command === 'watch' ? await waitFor(client, found.id, log) : await client.call(`/api/runs/${found.id}`);
189+
out(options.json ? JSON.stringify({ id: run.id, status: run.status, verdict: run.record?.verdict || null, final: run.final || '', error: run.error || null }, null, 2) : report(run));
190+
return VERDICT_EXIT[run.record?.verdict?.code] ?? (run.status === 'complete' ? 0 : 1);
191+
}
192+
if (command !== 'ask' && command !== 'review') fail(`Unknown command "${command}". Run overrule --help.`);
193+
194+
if (command === 'review' && !options.path) options.path = options._[1] || '.';
195+
const bootstrap = await client.connect();
196+
const members = chooseMembers(bootstrap, options);
197+
const workspace = workspacePayload(options);
198+
let prompt = command === 'review' ? options._.slice(1).filter(arg => arg !== options.path).join(' ') : options._.slice(1).join(' ');
199+
if (options.playbook) {
200+
const info = workspace ? await client.call('/api/workspace/inspect', 'POST', { path: workspace.path }) : {};
201+
const built = buildPlaybook(options.playbook, { name: info.name || (workspace ? basename(workspace.path) : ''), head: info.head || '', origin: '' });
202+
if (!built) fail(`No report template called "${options.playbook}". Run overrule playbooks.`);
203+
prompt = prompt ? `${built}\n\nALSO FROM THE OWNER\n${prompt}` : built;
204+
}
205+
if (!prompt.trim()) fail(command === 'review' ? 'Give a brief, or pick one with --playbook.' : 'Give a brief: overrule ask "your question".');
206+
207+
const attachmentIds = options.attach.length ? await attachDocuments(client, options.attach, log) : [];
208+
log(`Convening ${members.names.join(', ')}${workspace ? ` on ${workspace.path} (${workspace.level})` : ''}`);
209+
const run = await client.call('/api/runs', 'POST', {
210+
prompt, ...members, cycles: options.cycles ? number(options.cycles, 'cycles') : 2,
211+
revisions: options.revisions === undefined ? 1 : number(options.revisions, 'revisions'),
212+
research: Boolean(options.research) || Boolean(options['deep-research']), deepResearch: Boolean(options['deep-research']),
213+
...(options['turn-limit'] ? { timeoutSeconds: number(options['turn-limit'], 'turn-limit') } : {}),
214+
...(options['call-limit'] ? { maxCalls: number(options['call-limit'], 'call-limit') } : {}),
215+
...(options['time-limit'] ? { maxDurationSeconds: number(options['time-limit'], 'time-limit') * 60 } : {}),
216+
...(attachmentIds.length ? { attachmentIds } : {}), ...(workspace ? { workspace } : {}),
217+
});
218+
log(`Meeting ${run.id.slice(0, 8)} started. Watch it at ${client.url}`);
219+
if (options['no-wait']) { out(options.json ? JSON.stringify({ id: run.id, status: run.status }, null, 2) : run.id); return 0; }
220+
const finished = await waitFor(client, run.id, log);
221+
out(options.json ? JSON.stringify({ id: finished.id, status: finished.status, verdict: finished.record?.verdict || null, final: finished.final || '', error: finished.error || null, url: `${client.url}/#${finished.id}` }, null, 2) : report(finished));
222+
return VERDICT_EXIT[finished.record?.verdict?.code] ?? (finished.status === 'complete' ? 0 : 1);
223+
}
224+
225+
if (import.meta.url === `file://${process.argv[1]}`) {
226+
main(process.argv.slice(2))
227+
.then(code => process.exit(code))
228+
.catch(error => { console.error(error.expected ? error.message : `overrule: ${error.stack}`); process.exit(1); });
229+
}

0 commit comments

Comments
 (0)