@prplkane/roundtable 0.0.0-stage → 0.6.3

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/LICENSE +21 -0
  3. package/README.md +188 -3
  4. package/SECURITY.md +299 -0
  5. package/docs/workflow.md +1325 -0
  6. package/package.json +52 -6
  7. package/scripts/agent.mjs +40 -0
  8. package/scripts/export.mjs +20 -0
  9. package/scripts/mcp.mjs +175 -0
  10. package/scripts/run-debate.mjs +70 -0
  11. package/scripts/server.mjs +28 -0
  12. package/scripts/setup.mjs +182 -0
  13. package/skills/roundtable/SKILL.md +55 -0
  14. package/src/adapters/agy.js +7 -0
  15. package/src/adapters/catalogue.js +49 -0
  16. package/src/adapters/claude.js +6 -0
  17. package/src/adapters/codex.js +6 -0
  18. package/src/adapters/common.js +304 -0
  19. package/src/adapters/config.js +432 -0
  20. package/src/adapters/endpoint.js +178 -0
  21. package/src/adapters/errors.js +69 -0
  22. package/src/adapters/index.js +11 -0
  23. package/src/adapters/models.js +106 -0
  24. package/src/adapters/quota.js +305 -0
  25. package/src/adapters/spawn.js +178 -0
  26. package/src/agent-client.js +108 -0
  27. package/src/cli.js +44 -0
  28. package/src/contract.js +256 -0
  29. package/src/diagnostics.js +133 -0
  30. package/src/doctor.js +96 -0
  31. package/src/engine.js +902 -0
  32. package/src/estimate.js +331 -0
  33. package/src/export.js +307 -0
  34. package/src/fake.js +75 -0
  35. package/src/folder-picker.js +46 -0
  36. package/src/history.js +220 -0
  37. package/src/local-http.js +39 -0
  38. package/src/local-server.js +82 -0
  39. package/src/notes.js +16 -0
  40. package/src/personas.js +49 -0
  41. package/src/preflight.js +126 -0
  42. package/src/progress.js +30 -0
  43. package/src/prompts.js +278 -0
  44. package/src/research.js +113 -0
  45. package/src/review-view.js +157 -0
  46. package/src/seats.js +272 -0
  47. package/src/server.js +913 -0
  48. package/src/settings.js +27 -0
  49. package/src/setup-toml.js +124 -0
  50. package/src/snapshot.js +93 -0
  51. package/src/store.js +303 -0
  52. package/src/terminal.js +53 -0
  53. package/src/terms.js +52 -0
  54. package/src/ui/app.js +1967 -0
  55. package/src/ui/avatars.js +132 -0
  56. package/src/ui/ceremony.js +87 -0
  57. package/src/ui/design/app.css +714 -0
  58. package/src/ui/design/preset-dusk.css +91 -0
  59. package/src/ui/design/tokens.css +129 -0
  60. package/src/ui/diagnostics.js +61 -0
  61. package/src/ui/help.js +122 -0
  62. package/src/ui/icons.js +64 -0
  63. package/src/ui/index.html +272 -0
  64. package/src/ui/loader.js +73 -0
  65. package/src/ui/logo.svg +4 -0
  66. package/src/ui/scan.js +57 -0
  67. package/src/ui/scribe.js +64 -0
  68. package/src/ui/sound.js +80 -0
  69. package/src/validate.js +112 -0
  70. package/src/version.js +5 -0
@@ -0,0 +1,178 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { PROFILE_RE } from '../seats.js';
6
+ import { safeProgress } from '../progress.js';
7
+
8
+ const WIN = process.platform === 'win32';
9
+
10
+ // Find a real executable for `name` so spawn never needs a shell (Node refuses .cmd shims
11
+ // without one, and a shell would mangle quoted prompt text).
12
+ export function resolveBinary(name, npmBin, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
13
+ const P = platform === 'win32' ? path.win32 : path.posix;
14
+ const dirs = (env.PATH || '').split(P.delimiter).filter(Boolean);
15
+ const exts = platform === 'win32' ? ['.exe'] : [''];
16
+ for (const dir of dirs) {
17
+ for (const ext of exts) {
18
+ const p = P.join(dir, name + ext);
19
+ if (exists(p)) return { file: p, prefix: [] };
20
+ }
21
+ // Respect PATH order across npm shims and native binaries too. A later desktop CLI
22
+ // must not silently replace the npm CLI selected by the user's terminal.
23
+ if (npmBin && (platform !== 'win32' || ['.cmd', '.ps1'].some((ext) => exists(P.join(dir, name + ext))))) {
24
+ const [pkg, rel] = npmBin;
25
+ const script = P.join(dir, 'node_modules', pkg, rel);
26
+ if (exists(script)) return { file: process.execPath, prefix: [script] };
27
+ }
28
+ }
29
+ throw new Error(`${name}: not found on PATH`);
30
+ }
31
+
32
+ // Kill a CLI and everything it started: the process tree on Windows (taskkill /T), the process
33
+ // group elsewhere (the child is spawned as a group leader, so -pid reaches its helpers).
34
+ export function killTree(child) {
35
+ if (WIN) {
36
+ try { execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); } catch {}
37
+ } else {
38
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} }
39
+ }
40
+ }
41
+
42
+ // Every CLI child still running, with the tag its caller gave (the debate slug), so a stopped
43
+ // debate or a dying server can kill exactly those. Entries leave when the child closes.
44
+ const live = new Map(); // child (or a call token) -> { tag, onKill, virtual }
45
+ export function liveChildren(tag = null) {
46
+ return [...live.entries()].filter(([, v]) => tag == null || v.tag === tag).map(([child, v]) => ({ pid: v.virtual ? null : child.pid, tag: v.tag }));
47
+ }
48
+ // Kill the registered children (all, or those with `tag`). `kill` is injectable for tests.
49
+ export function killAll(tag = null, { kill = killTree } = {}) {
50
+ let n = 0;
51
+ for (const [child, v] of [...live.entries()]) {
52
+ if (tag != null && v.tag !== tag) continue;
53
+ v.onKill(); if (!v.virtual) kill(child); live.delete(child); n++;
54
+ }
55
+ return n;
56
+ }
57
+ // An in-process call (an HTTP request to an API endpoint) registered under a tag, so a stopped
58
+ // debate or a dying server aborts it like a child. Returns its abort signal and a release().
59
+ export function registerCall(tag = null) {
60
+ const token = {};
61
+ const ac = new AbortController();
62
+ live.set(token, { tag, virtual: true, onKill: () => ac.abort(Object.assign(new Error('killed: the debate was stopped or the server shut down'), { code: 'KILLED' })) });
63
+ return { signal: ac.signal, release: () => live.delete(token) };
64
+ }
65
+
66
+ // Run a CLI once. Resolves with {stdout, stderr, code, ms}; rejects on timeout (code TIMEOUT),
67
+ // on a kill through killAll (code KILLED) or on a spawn error.
68
+ // stallMs: stop the process once it has made no progress for this long (code STALL), for a call
69
+ // whose provider keeps the connection busy while producing nothing (the CLI's own idle limit
70
+ // then never fires). progressAt: when the caller last saw progress (the adapter leaves out a
71
+ // CLI's reconnect notices); without it, any output counts. 0: no such limit.
72
+ export function runProcess({ file, prefix }, args, { stdin = null, cwd, timeoutMs = 300_000, env, tag = null, onStdout, completionGraceMs = null, maxOutputBytes = 8 * 1024 * 1024, stallMs = 0, progressAt = null } = {}) {
73
+ return new Promise((resolve, reject) => {
74
+ const t0 = Date.now();
75
+ const child = spawn(file, [...prefix, ...args], { cwd, env, windowsHide: true, detached: !WIN, stdio: ['pipe', 'pipe', 'pipe'] });
76
+ let out = '', err = '', done = false, outputBytes = 0, stdoutBytes = 0, stderrBytes = 0, firstOutputMs, lastOutputAt = t0;
77
+ let completed = false, completionTimer, stallTimer;
78
+ const progress = (outcome, exitCode) => safeProgress({ elapsedMs: Date.now() - t0, timeoutMs, stdoutBytes, stderrBytes,
79
+ firstOutputMs, idleMs: Date.now() - lastOutputAt, outcome, exitCode });
80
+ const finish = (fn) => { if (done) return; done = true; clearTimeout(timer); clearTimeout(completionTimer); clearInterval(stallTimer); live.delete(child); fn(); };
81
+ live.set(child, { tag, onKill: () => finish(() => reject(Object.assign(new Error('killed: the debate was stopped or the server shut down'), { code: 'KILLED', stdout: out, stderr: err, ms: Date.now() - t0, progress: progress('cancelled') }))) });
82
+ const stop = (e) => finish(() => {
83
+ const metadata = progress(e.code === 'TOOL_POLICY' ? 'policy' : e.code === 'OUTPUT_LIMIT' ? 'output-limit' : e.code === 'CLI_FAILURE' ? 'provider-error' : e.code === 'STALL' ? 'timeout' : 'protocol');
84
+ killTree(child);
85
+ reject(Object.assign(e, { stdout: out, stderr: err, ms: metadata.elapsedMs, progress: metadata }));
86
+ });
87
+ const receive = (d, stdout) => {
88
+ if (done) return;
89
+ outputBytes += Buffer.byteLength(d);
90
+ if (stdout) stdoutBytes += Buffer.byteLength(d); else stderrBytes += Buffer.byteLength(d);
91
+ lastOutputAt = Date.now();
92
+ firstOutputMs ??= lastOutputAt - t0;
93
+ if (outputBytes > maxOutputBytes) { stop(Object.assign(new Error('Provider output exceeded the local size limit.'), { code: 'OUTPUT_LIMIT' })); return; }
94
+ if (stdout) out += d; else err += d;
95
+ try {
96
+ // Only a caller that validates a terminal event, answer and usage can signal this.
97
+ // Keep inspecting output during the grace period, then reap a stuck CLI without
98
+ // losing its completed answer or launching another paid request.
99
+ if (stdout && onStdout?.(d) === true && completionGraceMs > 0 && !completed) {
100
+ completed = true;
101
+ completionTimer = setTimeout(finishCompleted, completionGraceMs);
102
+ }
103
+ } catch (e) { stop(e); }
104
+ };
105
+ const finishCompleted = () => finish(() => {
106
+ const metadata = progress('completed');
107
+ killTree(child);
108
+ resolve({ stdout: out, stderr: err, code: null, completed: true, ms: metadata.elapsedMs, progress: metadata });
109
+ });
110
+ child.stdout.setEncoding('utf8').on('data', (d) => receive(d, true));
111
+ child.stderr.setEncoding('utf8').on('data', (d) => receive(d, false));
112
+ const timer = setTimeout(() => completed ? finishCompleted() : finish(() => {
113
+ const metadata = progress('timeout');
114
+ killTree(child);
115
+ reject(Object.assign(new Error(`timed out after ${timeoutMs} ms`), { code: 'TIMEOUT', stdout: out, stderr: err, ms: metadata.elapsedMs, progress: metadata }));
116
+ }), timeoutMs);
117
+ if (stallMs > 0) stallTimer = setInterval(() => {
118
+ if (!completed && Date.now() - (progressAt?.() ?? lastOutputAt) >= stallMs) stop(Object.assign(new Error(`no progress for ${Math.round(stallMs / 1000)} s`), { code: 'STALL' }));
119
+ }, Math.min(1000, stallMs));
120
+ child.on('error', (e) => finish(() => reject(Object.assign(e, { stdout: out, stderr: err, ms: Date.now() - t0, progress: progress('spawn-error') }))));
121
+ child.on('close', (code) => finish(() => resolve({ stdout: out, stderr: err, code, ms: Date.now() - t0, progress: progress('exited', code) })));
122
+ child.stdin.on('error', () => {}); // CLI may exit before reading stdin
123
+ child.stdin.end(stdin ?? '');
124
+ });
125
+ }
126
+
127
+ // Strip parent-agent session/bridge variables so a seat starts its own CLI session, including
128
+ // when the Roundtable server outlives the tool call that launched it. `home` points local
129
+ // state at the instance's own directory.
130
+ // Variables that would make a CLI bill an API account instead of the subscription it is logged
131
+ // in to. A seat runs on the subscription unless ROUNDTABLE_API_BILLING=1 says otherwise, so a
132
+ // key left in the shell cannot quietly move a debate onto pay-per-token.
133
+ export const API_KEY_VARS = /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_API_KEY|OPENAI_API_KEY|OPENAI_ACCESS_TOKEN|CODEX_API_KEY|GOOGLE_API_KEY|GEMINI_API_KEY|GOOGLE_GENAI_API_KEY)$/;
134
+ const CODEX_PARENT_VARS = /^(CODEX_APP_TOOLS_PIPE_PATH|CODEX_APP_RUNTIME_CLI_PATH|CODEX_INTERNAL_ORIGINATOR_OVERRIDE|CODEX_SESSION_ID|CODEX_THREAD_ID|CODEX_TOOL_CALL_ID|CODEX_PERMISSION_PROFILE)$/i;
135
+
136
+ export function cleanEnv(home = null, { allowApiKeys = process.env.ROUNDTABLE_API_BILLING === '1', source = process.env } = {}) {
137
+ const env = {};
138
+ for (const [k, v] of Object.entries(source)) {
139
+ if (/^(CLAUDECODE|CLAUDE_CODE_|CLAUDE_AGENT_SDK|CLAUDE_PID|CLAUDE_EFFORT|ANTHROPIC_BASE_URL)/.test(k)) continue;
140
+ if (CODEX_PARENT_VARS.test(k)) continue;
141
+ if (!allowApiKeys && API_KEY_VARS.test(k)) continue;
142
+ env[k] = v;
143
+ }
144
+ if (home) env[home.env] = home.dir;
145
+ return env;
146
+ }
147
+
148
+ // Where a named profile's CLI state lives: the OS user-data directory, never the project
149
+ // folder. One tree per profile name, one subdirectory per vendor inside it, so a profile is a
150
+ // login the user made themselves (`doctor` prints the command). Nothing is ever copied into it:
151
+ // the CLI's own login flow writes its credentials there, and roundtable never reads them.
152
+ // Windows %USERPROFILE%\\.roundtable\\homes\\<profile>\\<vendor>
153
+ // macOS ~/Library/Application Support/roundtable/homes/<profile>/<vendor>
154
+ // Linux $XDG_DATA_HOME/roundtable/homes/<profile>/<vendor>, default ~/.local/share
155
+ // Windows uses the user profile, not AppData, for the reason history.js dataDirectory gives: a
156
+ // packaged app's programs have their AppData writes redirected into that app's own copy, so a
157
+ // login made from one app would be missing in another.
158
+ export function dataDir({ platform = process.platform, env = process.env, homedir = os.homedir() } = {}) {
159
+ const P = platform === 'win32' ? path.win32 : path.posix; // the target platform's separators, so tests can ask for any
160
+ if (platform === 'win32') return P.join(homedir, '.roundtable');
161
+ if (platform === 'darwin') return P.join(homedir, 'Library', 'Application Support', 'roundtable');
162
+ return P.join(env.XDG_DATA_HOME || P.join(homedir, '.local', 'share'), 'roundtable');
163
+ }
164
+
165
+ export function profileHome(vendor, profile, where = {}) {
166
+ const name = String(profile || '').trim();
167
+ if (!PROFILE_RE.test(name)) throw new Error(`Profile name "${profile}" must be 1-40 letters, digits, dots, dashes or underscores.`);
168
+ const win = where.platform === 'win32' || (!where.platform && process.platform === 'win32');
169
+ const P = win ? path.win32 : path.posix;
170
+ const dir = P.join(dataDir(where), 'homes', name, vendor);
171
+ // a profile logged in under the earlier AppData default keeps its login until it is moved
172
+ if (win) {
173
+ const exists = where.exists || existsSync, env = where.env || process.env;
174
+ const earlier = env.LOCALAPPDATA && P.join(env.LOCALAPPDATA, 'roundtable', 'homes', name, vendor);
175
+ if (earlier && !exists(dir) && exists(earlier)) return earlier;
176
+ }
177
+ return dir;
178
+ }
@@ -0,0 +1,108 @@
1
+ import { LIMITS } from './validate.js';
2
+ import { localClient } from './local-http.js';
3
+
4
+ export const HELP = {
5
+ usage: 'roundtable agent <status|check|start|wait|answer|hold|finish|stop|resume|verdict> [--port 4747] [--id review-id] [--input file|-] [--seconds 25] [--after-round round] [--refresh]',
6
+ commands: {
7
+ status: 'Without --id: cached provider setup. With --id: current step, questions, latest positions and verdict. --refresh refreshes free CLI status checks.',
8
+ check: 'Validate --input and estimate calls/tokens without creating a review.',
9
+ start: 'Start the review in --input. Uses the selected providers. Returns its id immediately.',
10
+ wait: 'Wait up to --seconds (1–30) for a question or completion. After presenting questions, use --after-round N to wait for the next step without asking the same questions again. Returns one state object, not an event log.',
11
+ answer: 'Send --input {round, answers:[{id:"q1",text:"..."}], message:"optional note"}, labelled as an agent note. Read the current state first; never invent a missing answer.',
12
+ hold: 'Send --input {round} to give the user another answer window. This is a bounded extension, not an indefinite pause. It makes no model call.',
13
+ stop: 'Cancel all in-flight calls, including the chairman; keep completed replies. A verdict runs later only if explicitly requested.',
14
+ finish: 'End discussion and write the verdict from collected replies. Available after one complete round has a usable reply; may make a chairman call.',
15
+ resume: 'Resume an interrupted review. May make provider calls.',
16
+ verdict: 'Request a missing or failed verdict from saved replies. May make a provider call.',
17
+ },
18
+ setup: 'Use roundtable server status to inspect the local board; roundtable server start launches it hidden if needed (add --fake for a free test). After an update, server restart refuses active reviews. This client reads its launch token locally; it never prints or stores the token.',
19
+ input: 'check/start accept the same {fieldNotes,seats,chairman,options} JSON as the board. Check available providers first. Every result with an id carries board, a link where a person can watch the review in a browser; offer it, do not open it.',
20
+ recipe: {
21
+ questions: 'When nextAction.type is ask_user, present gate.questions with an available user-input tool; if unavailable, ask in normal chat. Tell the user the autoContinueAt deadline and that unknowns stay unknown. Do not just print JSON or answer for the user. In an async host, use nextAction.waitCommand while the question is pending; await_user means it has already been shown, so do not ask again. Read status before sending the user reply; if the round has advanced, preserve the reply and report that it was not submitted. Use hold only when the person needs more time, never as an automatic keepalive.',
22
+ size: 'Normally use two seats, critiqueRounds 1 (the engine default), memory stateless and an explicit budget per vendor: up to five calls before repairs. Use 0 only for a requested quick comparison and disclose that the seats did not respond to each other. Consider 2 for a requested deep review or a decision with several consequential trade-offs, within the user\'s time and budget. Consensus can end discussion early. Do not automatically start another review or exceed agreed limits because disagreement remains.',
23
+ completion: 'A start acknowledgement or seat positions are not a completed review. Keep waiting through status running and verdict until status done and verdict.status ready. If interrupted, failed, skipped or missing, report that limitation instead of supplying your own chairman result. Include the review id, board link and depth.completedCritiqueRounds in the final report.',
24
+ lenses: 'Give every seat a different lens, taken from what the decision could get wrong: risk, cost, evidence, reversibility, long-term effects, user impact. Two seats with one lens mostly agree, which wastes a call.',
25
+ moods: 'Pair one sceptical seat (grill) with one practical seat (builder). Use steelman when the user already leans one way, contrarian when the seats would otherwise agree too early.',
26
+ effort: 'effort low and the lightest model in status for the seats of a small review. Give the chairman one effort step more than the seats (medium when they run low): it judges the arguments, and at low effort it dismisses the hardest objection in a word. Raise one seat, not all, when the decision is hard to reverse.',
27
+ context: 'constraints, known, assumed, reversibility and deadline go into every prompt. Fill them only with facts the user stated; put guesses under assumed, never under known. Leave a field empty rather than invent it.',
28
+ unattended: 'Use options.unattended true for a background job with nobody to answer: it skips all pauses and keeps missing facts as assumptions. Live chat or browser reviews leave it false: ordinary checkpoints last autoContinueMs (15 seconds by default), questions last questionWaitMs (2 minutes). No answer continues automatically, without accepting a recommendation. Both modes respect the review wall clock.',
29
+ isolation: 'Live Antigravity requires options.allowLimitedIsolation=true. Set it only when the user has agreed to its limits: session rules deny documented tools, but the CLI cannot disable all shared customizations or MCP discovery. Choose another provider otherwise. Reported prohibited tool activity still stops the seat.',
30
+ terms: 'Live Antigravity also requires options.acknowledgeTermsRisk=true, for every seat, chairman or searcher on agy. Google\'s Antigravity FAQ says wrapping agy in third-party applications is not supported under consumer plans and that third-party access can get the account suspended or terminated. Show the person providers[].terms.notice and its url from status, and set the option only when they have agreed for this review; an acknowledgment on the board does not answer for an agent request. Choose another provider otherwise.',
31
+ research: 'Only when the user requests web research: options.research={query:"public search question",maxSearches:4,timeoutMs:120000}. Adds one fresh CLI call, the searcher, before discussion; only this query goes into research. Optional vendor ("codex", "claude" or "agy"; default "codex"; agy also needs allowLimitedIsolation and acknowledgeTermsRisk), model, effort and account set the searcher; without model and effort it runs that vendor\'s lightest model at low effort on the default login. All seats receive the same saved provider-supplied sources. Reported search operations are bounded, but an operation may start before its report arrives. Failed or interrupted research never reruns automatically.',
32
+ agreement: 'When gate.reopenCalls is present, every seat already agrees. Empty answers and no message go straight to the verdict. Any answer text or message runs one more round at reopenCalls calls. Send text only if it could change a seat\'s position; a fact that confirms what the seats already said is better left for the verdict.',
33
+ },
34
+ };
35
+
36
+ export async function runAgent(command, flags = {}, input, signal) {
37
+ if (command === 'guide') return HELP;
38
+ if (!Object.hasOwn(HELP.commands, command)) throw new Error('Unknown agent command. Use agent --help.');
39
+ const port = Number(flags.port || process.env.PORT || 4747);
40
+ if (!Number.isInteger(port) || port < LIMITS.port[0] || port > LIMITS.port[1]) throw new Error('Port must be a number from 1 to 65535.');
41
+ if (flags.id && !/^[a-z0-9-]+$/.test(flags.id)) throw new Error('Invalid review id.');
42
+ const { base, request } = localClient(port, signal);
43
+ const route = flags.id ? `/api/debates/${flags.id}` : null;
44
+ const board = (id) => `${base}/#${id}`; // where a person can watch this review in a browser
45
+ let result;
46
+ if (command === 'status' && !route) {
47
+ const setup = await request('/api/seats?cached=1');
48
+ const pools = setup.vendors.filter((v) => v.kind !== 'api').map((v) => v.vendor);
49
+ const readiness = flags.refresh ? await request('/api/preflight', { pools, fresh: true }) : await request('/api/preflight');
50
+ result = { mode: setup.runtime.mode, cataloguePending: setup.runtime.cataloguePending === true, providers: setup.vendors.map((v) => ({ vendor: v.vendor, kind: v.kind,
51
+ models: (v.catalogue?.models || []).map((m) => typeof m === 'string' ? m : m.id), efforts: v.catalogue?.efforts || [],
52
+ ...(v.terms ? { terms: { notice: v.terms.notice, url: v.terms.url, acknowledgedOnBoard: v.terms.acknowledgedAt != null } } : {}) })),
53
+ readiness: readiness.pools, defaults: setup.defaults, next: 'Check a JSON review with agent check --input file. No model call has been made.' };
54
+ } else if (command === 'check') result = await request('/api/preview', input);
55
+ else if (command === 'start') { const started = await request('/api/debates', input); result = { id: started.slug, board: board(started.slug), next: `agent wait --port ${port} --id ${started.slug}` }; }
56
+ else {
57
+ if (!route) throw new Error('This command needs --id from agent start or the board URL.');
58
+ if (command === 'status') result = { ...(await request(`${route}/state`)), board: board(flags.id) };
59
+ else if (command === 'wait') {
60
+ const seconds = Number(flags.seconds || 25);
61
+ if (!Number.isInteger(seconds) || seconds < LIMITS.waitSeconds[0] || seconds > LIMITS.waitSeconds[1]) throw new Error('Wait seconds must be a whole number from 1 to 30.');
62
+ const afterRound = flags['after-round'] == null ? -1 : Number(flags['after-round']);
63
+ if (!Number.isInteger(afterRound) || afterRound < -1 || afterRound > LIMITS.critiqueRounds[1] || flags['after-round'] != null && afterRound < 0) throw new Error('After round must be a whole number from 0 to 8.');
64
+ result = await request(`${route}/state`);
65
+ if (['researching', 'running', 'verdict'].includes(result.status) || result.status === 'waiting' && (result.gate?.round === afterRound || result.gate?.mode === 'countdown' && !result.gate.questions.length)) {
66
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), seconds * 1000);
67
+ let reader;
68
+ try {
69
+ const response = await fetch(`${base}${route}/events`, { redirect: 'error', signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, headers: { 'last-event-id': result.revision } });
70
+ if (!response.ok) throw new Error('Could not wait for review events.');
71
+ reader = response.body.getReader();
72
+ const decoder = new TextDecoder(); let buffer = '', finished = false;
73
+ while (!finished) {
74
+ const next = await reader.read(); if (next.done) break;
75
+ buffer += decoder.decode(next.value, { stream: true });
76
+ let end;
77
+ while ((end = buffer.indexOf('\n\n')) >= 0) {
78
+ const event = buffer.slice(0, end).split('\n').find((line) => line.startsWith('data: ')); buffer = buffer.slice(end + 2);
79
+ if (event) {
80
+ const update = JSON.parse(event.slice(6));
81
+ if (['done', 'error', 'interrupted'].includes(update.type) || update.type === 'gate' && update.round !== afterRound && (update.questions?.length || update.mode === 'blocking')) finished = true;
82
+ }
83
+ }
84
+ }
85
+ } catch (error) { signal?.throwIfAborted(); if (!controller.signal.aborted) throw error; }
86
+ finally { clearTimeout(timer); if (reader) await reader.cancel().catch(() => {}); }
87
+ result = await request(`${route}/state`);
88
+ }
89
+ result = { ...result, board: board(flags.id) };
90
+ if (result.nextAction?.type === 'ask_user' && result.gate.round === afterRound) {
91
+ result.nextAction = { ...result.nextAction, type: 'await_user', instruction: 'These questions have already been presented. Keep the existing prompt pending; do not ask again or invent answers. Read status before submitting a reply. Use waitCommand to wait for another step without extending the response window.' };
92
+ }
93
+ } else {
94
+ const body = structuredClone(input ?? {});
95
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Input must be a JSON object.');
96
+ if (['answer', 'finish', 'stop', 'resume'].includes(command)) body.source = 'agent';
97
+ if (['answer', 'hold'].includes(command) && !Number.isInteger(body.round)) throw new Error('An answer or time extension needs the round number from the current state.');
98
+ result = await request(`${route}/${command === 'answer' ? 'continue' : command === 'verdict' ? 'synthesize' : command}`, body);
99
+ result = { ...result, id: flags.id, board: board(flags.id), next: `agent wait --port ${port} --id ${flags.id}` };
100
+ }
101
+ }
102
+ if (['ask_user', 'await_user'].includes(result.nextAction?.type)) {
103
+ result.nextAction.command = `agent answer --port ${port} --id ${flags.id} --input -`;
104
+ result.nextAction.waitCommand = `agent wait --port ${port} --id ${flags.id} --after-round ${result.gate.round}`;
105
+ result.nextAction.extend = { command: `agent hold --port ${port} --id ${flags.id} --input -`, input: { round: result.gate.round } };
106
+ }
107
+ return { ok: true, ...result };
108
+ }
package/src/cli.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // The `roundtable` command (package.json "bin"). One entry point, the same scripts as
3
+ // `npm run`: it starts the right one with the arguments passed through.
4
+ //
5
+ // roundtable the board on http://localhost:4747, opened in your browser
6
+ // roundtable fake the board with scripted seats, nothing spent (port 4748)
7
+ // roundtable doctor [--live] what this machine can seat
8
+ // roundtable export <slug> <dir>
9
+ // roundtable debate [...] one debate headless against a running server (see the script)
10
+ // --port <n> --no-open for the two board commands
11
+ import { spawn } from 'node:child_process';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { terminalBanner } from './terminal.js';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const SCRIPTS = {
18
+ start: ['src/server.js'], fake: ['src/server.js', '--fake'], doctor: ['src/doctor.js'],
19
+ export: ['scripts/export.mjs'], debate: ['scripts/run-debate.mjs'], agent: ['scripts/agent.mjs'],
20
+ mcp: ['scripts/mcp.mjs'], setup: ['scripts/setup.mjs'], server: ['scripts/server.mjs'],
21
+ };
22
+
23
+ const args = process.argv.slice(2);
24
+ const USAGE = ['roundtable [--port <n>] [--no-open] the board, opened in your browser', 'roundtable fake [--port <n>] the board with scripted seats, nothing spent',
25
+ 'roundtable doctor [--live] what this machine can seat', 'roundtable export <slug> <dir> write a decision record into a codebase',
26
+ 'roundtable debate [--token <hex>] ... one debate headless, body on stdin',
27
+ 'roundtable agent --help JSON commands for agents; no GUI needed',
28
+ 'roundtable setup --client codex connect an assistant across projects (also claude, agy, all)',
29
+ 'roundtable setup --help inspect, update or remove that connection',
30
+ 'roundtable server --help check, start, stop or restart the hidden board',
31
+ 'roundtable mcp MCP tools over stdio; starts the local server as needed'].join('\n');
32
+ // help first, wherever it is on the line: a bare `--help` must never start the real server
33
+ if (!['agent', 'setup', 'mcp', 'server'].includes(args[0]) && args.some((a) => a === 'help' || a === '--help' || a === '-h')) { process.stdout.write(terminalBanner()); console.log(USAGE); process.exit(0); }
34
+ const cmd = args[0] && !args[0].startsWith('-') ? args.shift() : 'start';
35
+ if (!SCRIPTS[cmd]) { console.error(`unknown command: ${cmd}\n\n${USAGE}`); process.exit(2); }
36
+ const env = { ...process.env };
37
+ const port = args.indexOf('--port');
38
+ if (port >= 0 && ['start', 'fake'].includes(cmd)) { env.PORT = args[port + 1]; args.splice(port, 2); }
39
+ const [script, ...fixed] = SCRIPTS[cmd]; // only the script is a path; the rest are the server's own flags
40
+ const child = spawn(process.execPath, [path.join(ROOT, script), ...fixed, ...args], { stdio: 'inherit', env, windowsHide: true });
41
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
42
+ // A signal to this process goes to the child too; this process ends when the child does. (A
43
+ // handler that only waited swallowed SIGTERM on Linux, and `kill` could not stop it.)
44
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { try { child.kill(sig); } catch {} });
@@ -0,0 +1,256 @@
1
+ // The reply contract every seat must follow, and its parser.
2
+ //
3
+ // STANCE: <an option id (opt-1, opt-2, ...) or its exact label | other: <label>>
4
+ // AGREE_WITH: [labels] (empty in round 0)
5
+ // DISAGREE: <=3 bullets
6
+ // NEW: <=2 bullets or "none"
7
+ // ASSUMPTIONS: bullets
8
+ // QUESTION_FOR_HUMAN: one line, optional
9
+ // PASS (alone, when nothing new)
10
+
11
+ import { normLabel } from './validate.js';
12
+
13
+ export const HEADERS = ['STANCE', 'AGREE_WITH', 'DISAGREE', 'NEW', 'ASSUMPTIONS', 'QUESTION_FOR_HUMAN'];
14
+ // Parse decoration in separate passes: adjacent optional whitespace regexes can backtrack
15
+ // quadratically, especially when a provider puts a Unicode line separator inside a line.
16
+ function header(line, names, verdict = false) {
17
+ let rest = line.trimStart(), i = 0;
18
+ const opening = verdict ? '*#_-' : '*_`#>-';
19
+ while (i < rest.length && opening.includes(rest[i])) i++;
20
+ const decoration = rest.slice(0, i);
21
+ rest = rest.slice(i).trimStart();
22
+ const name = names.find((h) => rest.slice(0, h.length).toUpperCase() === h);
23
+ if (!name) return null;
24
+ rest = rest.slice(name.length).trimStart(); i = 0;
25
+ const closing = verdict ? '*_' : '*_`';
26
+ while (i < rest.length && closing.includes(rest[i])) i++;
27
+ const suffix = rest.slice(i).trimStart();
28
+ const closedBeforeColon = i > 0 && (suffix.startsWith(':') || decoration.endsWith(rest.slice(0, i)));
29
+ if (closedBeforeColon) rest = suffix;
30
+ if (rest.startsWith(':')) rest = rest.slice(1).trimStart();
31
+ else if (!verdict) return null;
32
+ let headerClosed = closedBeforeColon;
33
+ if (!closedBeforeColon && decoration) {
34
+ i = 0;
35
+ while (i < rest.length && closing.includes(rest[i])) i++;
36
+ if (i && decoration.endsWith(rest.slice(0, i)) && (i === rest.length || /\s/.test(rest[i]))) {
37
+ rest = rest.slice(i).trimStart(); headerClosed = true;
38
+ }
39
+ }
40
+ rest = rest.trimEnd();
41
+ // A still-open emphasis marker can wrap the entire header/value line. Remove only its
42
+ // matching closing marker, preserving literal punctuation in the value (e.g. Ship*).
43
+ if (!headerClosed && decoration) {
44
+ let start = decoration.length;
45
+ const mark = decoration[start - 1];
46
+ if (closing.includes(mark)) {
47
+ while (start > 0 && decoration[start - 1] === mark) start--;
48
+ const wrapper = decoration.slice(start);
49
+ if (rest.endsWith(wrapper)) rest = rest.slice(0, -wrapper.length).trimEnd();
50
+ }
51
+ }
52
+ // Keep the existing line grammar; these are not additional section delimiters.
53
+ if (/[\r\u2028\u2029]/.test(rest.trimEnd())) return null;
54
+ return [name, rest.trimEnd()];
55
+ }
56
+
57
+ function bullets(lines) {
58
+ return lines.map((l) => l.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim()).filter(Boolean);
59
+ }
60
+
61
+ function stripFence(text) {
62
+ let body = text.trim();
63
+ if (body.startsWith('```')) {
64
+ let i = 3;
65
+ while (i < body.length && /[a-z]/i.test(body[i])) i++;
66
+ body = body.slice(i).trimStart();
67
+ }
68
+ return body.endsWith('```') ? body.slice(0, -3).trimEnd() : body;
69
+ }
70
+
71
+ // Ignore explicit no-question placeholders, including a short declarative explanation.
72
+ // Be conservative: punctuation is not required for real questions, and an uncertain suffix
73
+ // remains a question. In particular, "None of these options ..." is not a placeholder.
74
+ export function humanQuestion(value) {
75
+ const text = String(value || '').trim();
76
+ if (!text) return null;
77
+ const plain = text.replace(/[*_`]/g, '').replace(/^[-•]\s+/, '').trim();
78
+ if (/^[-–—]+[.!]?$/.test(plain)) return null;
79
+ if (/[??]/.test(plain)) return text;
80
+ const match = /^(?:none|n\/?a|not applicable|no (?:further |additional |unanswered )?questions?(?: for (?:the )?human)?)(?:\s*[.,;:!–—-]\s*|$)([\s\S]*)$/i.exec(plain);
81
+ if (!match) return text;
82
+ const explanation = match[1].trim();
83
+ if (!explanation) return null;
84
+ if (/[\r\u2028\u2029]/.test(explanation)) return text;
85
+ // These are bookkeeping statements, not a request for an answer. Other suffixes stay live.
86
+ if (/^(?:treat [^.;:!?]{1,160} as (?:an? )?(?:explicit )?(?:unknown|assumption)s?|(?:all |the )?questions? (?:are |were |have been )?answered|(?:the |this )?(?:information|answer|detail|value) (?:is |remains )?(?:unknown|not provided))[.!]?$/i.test(explanation)
87
+ && !/\b(?:who|what|when|where|why|how|which|whether|please|confirm|clarify)\b/i.test(explanation)) return null;
88
+ return text;
89
+ }
90
+
91
+ // Split the reply into sections keyed by header. Text before the first header is ignored.
92
+ export function parseReply(text) {
93
+ const body = stripFence(text || '').trim();
94
+ if (/^\**PASS\**\.?$/i.test(body)) return { pass: true, stance: null, agreeWith: [], disagree: [], new: [], assumptions: [], question: null, raw: text };
95
+
96
+ const sections = {};
97
+ const duplicates = [];
98
+ let current = null;
99
+ for (const line of body.split(/\r?\n/)) {
100
+ const m = header(line, HEADERS);
101
+ if (m) {
102
+ current = m[0];
103
+ if (Object.hasOwn(sections, current)) duplicates.push(current);
104
+ sections[current] = m[1].trim() ? [m[1].trim()] : [];
105
+ } else if (current) {
106
+ sections[current].push(line);
107
+ }
108
+ }
109
+ const one = (k) => (sections[k] || []).join(' ').trim();
110
+ const list = (k) => bullets((sections[k] || []));
111
+ const agree = one('AGREE_WITH').replace(/^\[|\]$/g, '').split(/[,;]/).map((s) => s.trim()).filter((s) => s && !/^(none|empty|-)$/i.test(s));
112
+ const question = one('QUESTION_FOR_HUMAN');
113
+ return {
114
+ pass: false,
115
+ stance: one('STANCE') || null,
116
+ agreeWith: agree,
117
+ disagree: list('DISAGREE'),
118
+ new: list('NEW'),
119
+ assumptions: list('ASSUMPTIONS'),
120
+ question: humanQuestion(question),
121
+ sections: Object.keys(sections),
122
+ duplicates,
123
+ raw: text,
124
+ };
125
+ }
126
+
127
+ // Options have ids, opt-1 ... opt-N in field-notes order. Every comparison (stance keys, the
128
+ // tally, the chosen option) uses the id; the label is for display. So "Ship" and "Ship later"
129
+ // stay apart, non-Latin labels stay apart, and no label can collide with PASS or "other:".
130
+ export const optionIds = (options) => (options || []).map((label, i) => ({ id: `opt-${i + 1}`, label }));
131
+ export function optionLabel(key, options) {
132
+ return optionIds(options).find((o) => o.id === key)?.label ?? key;
133
+ }
134
+
135
+ // The option a piece of text names: its id, its exact label (after NFKC, case and whitespace
136
+ // folding; nothing stripped), or the id followed by its own label ("opt-2: Wait a sprint").
137
+ // Quotes and a trailing full stop are tolerated. Anything else names no option.
138
+ export function optionId(text, options) {
139
+ if (text == null) return null;
140
+ const ids = optionIds(options).map((o) => ({ ...o, normalized: normLabel(o.label) }));
141
+ const match = (s) => {
142
+ for (const o of ids) if (s === o.id || s === o.normalized) return o.id;
143
+ const m = /^(opt-\d+)/.exec(s);
144
+ const o = m && ids.find((x) => x.id === m[1]);
145
+ if (!o) return null;
146
+ let label = s.slice(m[1].length).trimStart();
147
+ if (label && ':=(\u2014\u2013-'.includes(label[0])) label = label.slice(1).trimStart();
148
+ if (!label || label === o.normalized) return o.id;
149
+ if (label.endsWith(')') && label.slice(0, -1) === o.normalized) return o.id;
150
+ return null;
151
+ };
152
+ const raw = normLabel(String(text));
153
+ const exact = match(raw);
154
+ if (exact) return exact;
155
+ let end = raw.length, start = 0;
156
+ while (end && '.;'.includes(raw[end - 1])) end--;
157
+ // Peel only matching wrappers, testing the literal label at each layer. Removing all
158
+ // stars/quotes at once can turn a formatted Ship* response into the different option Ship.
159
+ // Only short cores can name a bounded option; huge wrapper runs still take linear work.
160
+ const maximum = Math.max(0, ...ids.map((o) => o.normalized.length + o.id.length + 4));
161
+ for (;;) {
162
+ while (start < end && raw[start] === ' ') start++;
163
+ while (end > start && raw[end - 1] === ' ') end--;
164
+ if (end - start <= maximum) {
165
+ const pick = match(raw.slice(start, end));
166
+ if (pick) return pick;
167
+ }
168
+ if (end - start < 2 || raw[start] !== raw[end - 1] || !'"\'`*'.includes(raw[start])) return null;
169
+ start++; end--;
170
+ }
171
+ }
172
+
173
+ // A seat's STANCE as a key: an option id, `other: <label>`, or null when it names nothing.
174
+ export function matchStance(stance, options) {
175
+ if (!stance) return null;
176
+ const id = optionId(stance, options);
177
+ if (id) return id;
178
+ const body = stance.trim(), other = /^other\s*:/i.exec(body);
179
+ const label = other && body.slice(other[0].length).trim();
180
+ if (label) return `other: ${label}`;
181
+ return null;
182
+ }
183
+
184
+ // Bullet limits are repaired here, not re-prompted: a re-prompt is a whole extra call, and the
185
+ // extra bullets stay in the reply text the board and the other seats read.
186
+ export const BULLET_LIMITS = { disagree: 3, new: 2 };
187
+
188
+ // Returns {ok, errors[], parsed}. A violation list feeds the single re-prompt; parsed.trimmed
189
+ // names the sections cut back to their limit.
190
+ // trim: false keeps every bullet (a hidden mood's rant is the point, not a list to keep short).
191
+ // requireAgree: false reads a missing AGREE_WITH line in a later round as agreeing with nobody.
192
+ export function validateReply(text, { options, round, trim = true, requireAgree = true }) {
193
+ const parsed = parseReply(text);
194
+ const errors = [];
195
+ for (const h of parsed.duplicates || []) errors.push(`duplicate ${h} section`);
196
+ if (parsed.pass) return { ok: true, errors, parsed: { ...parsed, stanceKey: 'PASS' } };
197
+ const stanceKey = matchStance(parsed.stance, options);
198
+ if (!parsed.stance) errors.push('missing STANCE line');
199
+ else if (!stanceKey) errors.push(`STANCE "${parsed.stance}" is not an option id (${optionIds(options).map((o) => o.id).join(', ')}) or an exact option label, and is not written as "other: <label>"`);
200
+ for (const h of ['DISAGREE', 'NEW', 'ASSUMPTIONS']) {
201
+ if (!(parsed.sections || []).includes(h)) errors.push(`missing ${h} section`);
202
+ }
203
+ if (requireAgree && round > 0 && !(parsed.sections || []).includes('AGREE_WITH')) errors.push('missing AGREE_WITH line (use [] if none)');
204
+ const trimmed = [];
205
+ for (const [key, limit] of trim ? Object.entries(BULLET_LIMITS) : []) {
206
+ if (parsed[key].length > limit) { parsed[key] = parsed[key].slice(0, limit); trimmed.push(key.toUpperCase()); }
207
+ }
208
+ return { ok: errors.length === 0, errors, parsed: { ...parsed, stanceKey, ...(trimmed.length ? { trimmed } : {}) } };
209
+ }
210
+
211
+ // ---- The chairman's verdict -----------------------------------------------------------------
212
+
213
+ // TALLY is still read (verdicts before 0.3.1 have one) but no longer asked for: the tally is
214
+ // counted in code from the seats' final stances, exactly, and writing it cost output tokens.
215
+ export const VERDICT_HEADERS = ['RECOMMENDATION', 'TALLY', 'DISSENT', 'KILL_CRITERIA', 'OPEN_QUESTIONS'];
216
+ export const REQUIRED_VERDICT = ['RECOMMENDATION', 'DISSENT', 'KILL_CRITERIA', 'OPEN_QUESTIONS'];
217
+
218
+ // The verdict's sections, tolerant of markdown emphasis around a header.
219
+ export function verdictSections(text, duplicates = []) {
220
+ const sections = {};
221
+ let cur = null;
222
+ for (const line of String(text || '').split(/\r?\n/)) {
223
+ const m = header(line, VERDICT_HEADERS, true);
224
+ if (m) {
225
+ cur = m[0];
226
+ if (Object.hasOwn(sections, cur)) duplicates.push(cur);
227
+ sections[cur] = m[1] ? [m[1]] : [];
228
+ } else if (cur) sections[cur].push(line);
229
+ }
230
+ return sections;
231
+ }
232
+
233
+ // The pick on the recommendation's first line: an option id, 'none' (an explicit abstention),
234
+ // or null when the line names neither.
235
+ export function recommendationPick(firstLine, options) {
236
+ const line = String(firstLine || '').trim();
237
+ if (/^\**\s*(none|no pick)\s*\.?\**$/i.test(line)) return 'none';
238
+ return optionId(line, options);
239
+ }
240
+
241
+ // Since 0.3.0 the verdict has a grammar: every section present and non-empty, and the
242
+ // recommendation's first line is exactly an option id (or its label) or none, with the rationale
243
+ // below it. Returns { ok, errors, pick }.
244
+ export function validateVerdict(text, options) {
245
+ const duplicates = [];
246
+ const sections = verdictSections(text, duplicates);
247
+ const errors = duplicates.map((h) => `duplicate ${h} section`);
248
+ for (const h of REQUIRED_VERDICT) {
249
+ if (!sections[h]) errors.push(`missing ${h} section`);
250
+ else if (!sections[h].some((l) => l.trim())) errors.push(`${h} is empty`);
251
+ }
252
+ const first = (sections.RECOMMENDATION || []).find((l) => l.trim()) || '';
253
+ const pick = recommendationPick(first, options);
254
+ if (sections.RECOMMENDATION && !pick) errors.push(`the first line of RECOMMENDATION must be one option id (${optionIds(options).map((o) => o.id).join(', ')}) or none, with the rationale on the lines below`);
255
+ return { ok: errors.length === 0, errors, pick };
256
+ }