|
| 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