Skip to content

Commit ad86f97

Browse files
binary-knightclaude
andcommitted
Act on the terminus session's feedback: tell dissent from a spent budget, return the record, resume and scope from the CLI
A meeting that ran out of turns or stopped moving with nobody voting against now reports unsettled and exits 5, so a caller can buy more turns instead of reading that as disagreement; the verdict also carries dissent, the stop reason, and the votes. --json returns the ballots, the objections with the condition that resolves each one, the candidate text, the members and the metrics, so a dissent can be quoted without scraping Markdown. New verbs resume and reconvene, with resume carrying a budget-closed meeting on for more cycles: the floor reopens on the same record and the next draft is a new candidate version. The seats are printed before the first call, naming which of them bill per token. --exclude puts paths out of scope as an instruction to the members, said plainly to be an instruction and not a boundary, and a document is now put to members as material to weigh on its own terms rather than as the owner's argument. Also fixes stdout being truncated when the output was piped, which made an early JSON read come back empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3f017fb commit ad86f97

10 files changed

Lines changed: 167 additions & 30 deletions

File tree

‎AGENTS.md‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,18 @@ If that prints meetings, you are connected. If it says no server, tell the perso
2222
overrule review . --playbook changes --cycles 1 --json
2323
```
2424

25-
The council reads your working tree, finds the change with `git status` and `git diff`, and judges that change, not the project. The exit status is the answer: **0** approved, **2** objections remain, **3** checks failed, **4** verification incomplete, **1** something went wrong.
25+
The council reads your working tree, finds the change with `git status` and `git diff`, and judges that change, not the project. The exit status is the answer:
26+
27+
| Status | Meaning |
28+
| --- | --- |
29+
| 0 | Approved. Every voter approved and nothing is open. |
30+
| 2 | A member voted against the candidate. Real dissent; read the objections. |
31+
| 3 | A command the council ran on the candidate failed. |
32+
| 4 | Verification incomplete: there is a candidate, but the vote did not finish. |
33+
| 5 | Unsettled: the meeting ran out of turns or stopped moving, with nobody dissenting. Buy it more turns rather than treating it as disagreement. |
34+
| 1 | Something went wrong. Read the message. |
35+
36+
Nothing is printed on standard output until the verdict; progress goes to standard error. Do not pipe the output into a parser before the command exits.
2637

2738
**Ask a question that has no single right answer.** Design choices, migration plans, tradeoffs:
2839

@@ -39,8 +50,11 @@ Read the whole report, not just the verdict. The value is in the objections, whi
3950
- **Approved.** Say so, and name anything the council flagged anyway.
4051
- **Objections remain.** Do not treat this as failure. Report the objections to the person in their own words, say which ones you agree with, and fix what is worth fixing.
4152
- **Checks failed.** A command the council ran on the candidate failed. That is a fact, not an opinion; act on it.
53+
- **Unsettled.** The clock ran out, not the argument. `overrule resume <id> --cycles 3` carries on with the same council and record instead of paying for the openings again, and `--note "<what to settle>"` goes in as the owner's own message.
4254
- **Error.** Report what the message said. Do not retry in a loop.
4355

56+
With `--json` you get the record, not only the prose: `verdict` (with `dissent`, `stopReason`, and the vote counts), `ballots[]`, `objections[]` with each one's `resolvingCondition`, the `candidate` text, `members`, `metrics`, and the meeting `url`. Quote from those fields rather than scraping the Markdown.
57+
4458
Never present the council's findings as your own verification. Say where they came from. If a finding contradicts what you know about the code, check it yourself before passing it on: the members read the tree, but they can be wrong, and two of them agreeing does not make a thing true.
4559

4660
## Rules
@@ -56,12 +70,14 @@ Never present the council's findings as your own verification. Say where they ca
5670
| Option | What it does |
5771
| --- | --- |
5872
| `--playbook <id>` | Start from a report template. `overrule playbooks` lists them: security, changes, bugs, dependencies, architecture, tests, performance, readiness. |
59-
| `--members <names>` | Pick the council by connection name. The default seats everything ready, up to eight. |
73+
| `--members <names>` | Pick the council by connection name. **The default seats everything ready, up to eight, which can include members billed per token on the owner's API accounts.** The command prints the seats and which of them bill before the first call. |
6074
| `--cycles <n>` | Turns per member. 1 is a quick read, 2 is a real argument, more is a long meeting. |
6175
| `--deep-research` | Members that can browse research the subject before taking a position, citing sources. Slower and more expensive. |
62-
| `--attach <file>` | Give the council a document: a plan, a diff, a spec. Repeatable. |
76+
| `--attach <file>` | Give the council a document: a plan, a diff, a spec. Repeatable. Documents are put to the members as material to weigh on its own terms, separate from the brief, which is the owner's framing. Attach the source rather than your summary of it. |
77+
| `--exclude <path>` | Tell members a path is out of scope, so their positions are not anchored on your notes. An instruction to the council, not a boundary the machine enforces. Repeatable. |
6378
| `--no-wait` | Print the meeting id and return immediately; pick it up later with `overrule watch <id>`. |
64-
| `--json` | One object: `{ id, status, verdict, final, error, url }`. Use this, and read `final`. |
79+
| `--json` | The whole record as one object: verdict, ballots, objections, candidate, members, metrics, url. Use this. |
80+
| `--turn-limit <s>` | Seconds one member may take. Raise it above the 180 second default when research is on, or a slow member loses its seat. |
6581

6682
## If you are working on Overrule itself
6783

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ node bin/overrule.mjs ask "Is this migration plan sound?" --deep-research --json
8787
node bin/overrule.mjs list
8888
```
8989

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.
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 because a member voted against, **3** checks failed, **4** verification incomplete, **5** unsettled because the meeting ran out of turns with nobody dissenting, **1** error. `resume <id> --cycles 3` buys an unsettled meeting more turns without paying for the openings again, and `reconvene <id> "<what next>"` opens a new session on the same record. With `--json` the output is one object holding the verdict (with the vote counts, whether anyone dissented, and why the meeting closed), the ballots, the objections with the condition that resolves each one, the candidate text, the members, 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.
9191

9292
A coding agent can therefore finish a change and hand it straight to the council:
9393

‎bin/overrule.mjs‎

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const USAGE = `overrule — convene a council of models from the command line.
1010
1111
overrule ask "<brief>" [options] Hold a meeting about a question.
1212
overrule review [path] [options] Review a workspace, usually with a report template.
13+
overrule resume <id> [options] Pick a stopped meeting up, optionally with more turns.
14+
overrule reconvene <id> "<what next>" Reopen a closed meeting with a new instruction.
1315
overrule watch <id> Follow a meeting that is already running.
1416
overrule show <id> [--json] Print a finished meeting's report.
1517
overrule list [--json] List recent meetings.
@@ -26,6 +28,8 @@ Options
2628
--research Members that can browse may search the web.
2729
--deep-research They research the subject before taking a position. Implies --research.
2830
--attach <file> Attach a document. Repeatable.
31+
--exclude <path> Tell members a path is out of scope. Repeatable. An instruction to
32+
the council, not a boundary the machine enforces.
2933
--check <command> A command run on the candidate commit at a write level. Repeatable.
3034
--network Allow the network for the implementer and the checks at a write level.
3135
--implement-limit <s> Seconds the implementer may take at a write level.
@@ -34,20 +38,23 @@ Options
3438
--call-limit <n> Model calls the whole session may make.
3539
--acknowledge Accept the warnings this meeting needs (secrets in the tree, uncommitted work).
3640
--acknowledge-full-access Required for --level full-access.
41+
--note <text> A message to the council, delivered with resume.
3742
--no-wait Print the meeting id and exit instead of waiting.
3843
--json Print one JSON object instead of a report.
3944
--quiet Print the report only, with no progress.
4045
--url <address> Server to use. Default ${DEFAULT_URL}.
4146
--code <pairing code> Pair first; needed only when the server is not on this machine.
4247
43-
Exit status: 0 approved, 2 objections remain, 3 checks failed, 4 verification incomplete, 1 error.`;
48+
Exit status: 0 approved, 2 objections remain (a member voted against), 3 checks failed,
49+
4 verification incomplete, 5 unsettled (out of turns or stalled, with nobody dissenting;
50+
resume with more cycles), 1 error.`;
4451

45-
const VERDICT_EXIT = { approved: 0, objections: 2, 'checks-failed': 3, incomplete: 4 };
52+
const VERDICT_EXIT = { approved: 0, objections: 2, 'checks-failed': 3, incomplete: 4, unsettled: 5 };
4653
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']);
54+
const MANY = new Set(['attach', 'check', 'exclude']);
4855

4956
export function parseArgs(argv) {
50-
const options = { _: [], attach: [], check: [] };
57+
const options = { _: [], attach: [], check: [], exclude: [] };
5158
for (let i = 0; i < argv.length; i++) {
5259
const arg = argv[i];
5360
if (!arg.startsWith('--')) { options._.push(arg); continue; }
@@ -114,7 +121,7 @@ function chooseMembers(bootstrap, options) {
114121
chosen = chosen.slice(0, 8);
115122
const drafter = options.drafter ? chosen.find(p => p.name.toLowerCase() === options.drafter.toLowerCase() || p.id === options.drafter) : chosen[0];
116123
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) };
124+
return { participantIds: chosen.map(p => p.id), drafterId: drafter.id, names: chosen.map(p => p.name), seats: chosen };
118125
}
119126

120127
async function attachDocuments(client, files, log) {
@@ -137,7 +144,7 @@ function workspacePayload(options) {
137144
if (!level) return undefined;
138145
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.');
139146
return {
140-
path, level, checks: options.check, network: options.network === true || level === 'full-access',
147+
path, level, checks: options.check, exclude: options.exclude, network: options.network === true || level === 'full-access',
141148
acknowledgeSecrets: Boolean(options.acknowledge), acknowledgeDirty: Boolean(options.acknowledge), acknowledgeFullAccess: Boolean(options['acknowledge-full-access']),
142149
...(options['implement-limit'] ? { implementTimeout: Number(options['implement-limit']) } : {}),
143150
};
@@ -155,6 +162,28 @@ async function waitFor(client, id, log) {
155162
}
156163
}
157164

165+
// Everything a calling agent would otherwise have to scrape out of the Markdown: the ballots, the objections with the
166+
// condition that resolves each one, the candidate text, and why the meeting closed.
167+
function machineReadable(run, url) {
168+
const record = run.record || {};
169+
const draft = [...(run.entries || [])].reverse().find(e => e.phase === 'draft' && e.status === 'complete' && !e.superseded && e.candidateVersion === run.candidateVersion);
170+
const name = id => (run.participants || []).find(p => p.id === id)?.name || id;
171+
return {
172+
id: run.id, status: run.status, url: `${url}/#${run.id}`,
173+
verdict: record.verdict ? { ...record.verdict, votes: record.votes || null, voters: record.voters ?? null, missingBallots: record.missingBallots ?? null } : null,
174+
stopReason: run.stopReason || null, cycles: run.cycles, session: run.session || 1,
175+
final: run.final || '', candidate: draft?.text || '', candidateVersion: run.candidateVersion,
176+
ballots: (run.entries || []).filter(e => e.phase === 'ratify' && e.status === 'complete' && !e.superseded && e.candidateVersion === run.candidateVersion)
177+
.map(e => ({ member: e.name, vote: e.fields?.vote || 'abstain', reason: e.fields?.reason || '', objections: (e.fields?.objections || []).map(o => ({ claim: o.claim, resolvingCondition: o.condition })) })),
178+
objections: (run.issues || []).map(issue => ({ id: issue.id, raisedBy: name(issue.raisedBy), against: name(issue.against), claim: issue.claim, resolvingCondition: issue.condition, status: issue.status })),
179+
openObjections: (run.issues || []).filter(i => i.status === 'open').length,
180+
members: (run.participants || []).map(p => p.name), drafter: name(run.drafterId),
181+
workspace: run.workspace ? { path: run.workspace.path, level: run.workspace.level, origin: run.workspace.origin || null, head: run.workspace.head || null } : null,
182+
research: Boolean(run.research), deepResearch: Boolean(run.deepResearch),
183+
metrics: record.metrics || null, error: run.error || null,
184+
};
185+
}
186+
158187
function report(run) {
159188
const verdict = run.record?.verdict;
160189
return [
@@ -181,12 +210,36 @@ export async function main(argv, { out = console.log, err = console.error } = {}
181210
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)}`);
182211
return 0;
183212
}
213+
if (command === 'resume' || command === 'reconvene') {
214+
const id = options._[1] || fail(`overrule ${command} needs a meeting id.`);
215+
await client.connect();
216+
const found = (await client.call('/api/runs')).find(run => run.id === id || run.id.startsWith(id)) || fail(`No meeting starts with ${id}.`);
217+
const body = {
218+
...(options.cycles ? { cycles: number(options.cycles, 'cycles') } : {}),
219+
...(options.revisions === undefined ? {} : { revisions: number(options.revisions, 'revisions') }),
220+
...(options['turn-limit'] ? { timeoutSeconds: number(options['turn-limit'], 'turn-limit') } : {}),
221+
...(options['call-limit'] ? { maxCalls: number(options['call-limit'], 'call-limit') } : {}),
222+
...(options['time-limit'] ? { maxDurationSeconds: number(options['time-limit'], 'time-limit') * 60 } : {}),
223+
...(options.research || options['deep-research'] ? { research: true } : {}), ...(options['deep-research'] ? { deepResearch: true } : {}),
224+
};
225+
if (command === 'reconvene') {
226+
const text = options._.slice(2).join(' ').trim() || options.note || fail('Say what the council should take up next: overrule reconvene <id> "…".');
227+
await client.call(`/api/runs/${found.id}/reconvene`, 'POST', { text, cycles: body.cycles ?? 2, maxCalls: body.maxCalls, maxDurationSeconds: body.maxDurationSeconds });
228+
} else {
229+
await client.call(`/api/runs/${found.id}/resume`, 'POST', { ...body, note: options.note || '' });
230+
}
231+
log(`${command === 'resume' ? 'Resumed' : 'Reconvened'} ${found.id.slice(0, 8)}.`);
232+
if (options['no-wait']) { out(options.json ? JSON.stringify({ id: found.id, status: 'running' }, null, 2) : found.id); return 0; }
233+
const finished = await waitFor(client, found.id, log);
234+
out(options.json ? JSON.stringify(machineReadable(finished, client.url), null, 2) : report(finished));
235+
return VERDICT_EXIT[finished.record?.verdict?.code] ?? (finished.status === 'complete' ? 0 : 1);
236+
}
184237
if (command === 'show' || command === 'watch') {
185238
const id = options._[1] || fail(`overrule ${command} needs a meeting id.`);
186239
await client.connect();
187240
const found = (await client.call('/api/runs')).find(run => run.id === id || run.id.startsWith(id)) || fail(`No meeting starts with ${id}.`);
188241
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));
242+
out(options.json ? JSON.stringify(machineReadable(run, client.url), null, 2) : report(run));
190243
return VERDICT_EXIT[run.record?.verdict?.code] ?? (run.status === 'complete' ? 0 : 1);
191244
}
192245
if (command !== 'ask' && command !== 'review') fail(`Unknown command "${command}". Run overrule --help.`);
@@ -205,9 +258,12 @@ export async function main(argv, { out = console.log, err = console.error } = {}
205258
if (!prompt.trim()) fail(command === 'review' ? 'Give a brief, or pick one with --playbook.' : 'Give a brief: overrule ask "your question".');
206259

207260
const attachmentIds = options.attach.length ? await attachDocuments(client, options.attach, log) : [];
208-
log(`Convening ${members.names.join(', ')}${workspace ? ` on ${workspace.path} (${workspace.level})` : ''}`);
261+
const billed = members.seats.filter(p => !bootstrap.types[p.type]?.cli);
262+
const seating = `Seating ${members.seats.map(p => `${p.name}${bootstrap.types[p.type]?.cli ? '' : ' (billed per token)'}`).join(', ')}${workspace ? ` on ${workspace.path} (${workspace.level})` : ''}`;
263+
if (!options.quiet) err(seating + (billed.length && !options.members ? `\n${billed.length} of these bill your API accounts per token. Pass --members to choose the council yourself.` : ''));
264+
const { seats, names, ...meeting } = members;
209265
const run = await client.call('/api/runs', 'POST', {
210-
prompt, ...members, cycles: options.cycles ? number(options.cycles, 'cycles') : 2,
266+
prompt, ...meeting, cycles: options.cycles ? number(options.cycles, 'cycles') : 2,
211267
revisions: options.revisions === undefined ? 1 : number(options.revisions, 'revisions'),
212268
research: Boolean(options.research) || Boolean(options['deep-research']), deepResearch: Boolean(options['deep-research']),
213269
...(options['turn-limit'] ? { timeoutSeconds: number(options['turn-limit'], 'turn-limit') } : {}),
@@ -218,12 +274,13 @@ export async function main(argv, { out = console.log, err = console.error } = {}
218274
log(`Meeting ${run.id.slice(0, 8)} started. Watch it at ${client.url}`);
219275
if (options['no-wait']) { out(options.json ? JSON.stringify({ id: run.id, status: run.status }, null, 2) : run.id); return 0; }
220276
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));
277+
out(options.json ? JSON.stringify(machineReadable(finished, client.url), null, 2) : report(finished));
222278
return VERDICT_EXIT[finished.record?.verdict?.code] ?? (finished.status === 'complete' ? 0 : 1);
223279
}
224280

225281
if (import.meta.url === `file://${process.argv[1]}`) {
282+
// process.exit truncates a piped stdout: set the status and let the process end once the report has been written.
226283
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); });
284+
.then(code => { process.exitCode = code; })
285+
.catch(error => { console.error(error.expected ? error.message : `overrule: ${error.stack}`); process.exitCode = 1; });
229286
}

0 commit comments

Comments
 (0)