@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
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
- {
2
- "name": "@prplkane/roundtable",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@prplkane/roundtable",
3
+ "version": "0.6.3",
4
+ "description": "Local decision reviews with coding-agent CLIs, human input, budget checks and Markdown records.",
5
+ "keywords": [
6
+ "claude",
7
+ "codex",
8
+ "antigravity",
9
+ "cli",
10
+ "debate",
11
+ "decision",
12
+ "multi-agent",
13
+ "subscription"
14
+ ],
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "bin": {
18
+ "roundtable": "src/cli.js"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "scripts",
23
+ "skills/roundtable/SKILL.md",
24
+ "docs/workflow.md",
25
+ "README.md",
26
+ "SECURITY.md",
27
+ "CHANGELOG.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "start": "node src/server.js",
32
+ "fake": "node src/server.js --fake",
33
+ "test": "node --test test/*.test.js",
34
+ "doctor": "node src/doctor.js",
35
+ "export": "node scripts/export.mjs",
36
+ "debate": "node scripts/run-debate.mjs"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/prplkane/roundtable.git"
44
+ },
45
+ "homepage": "https://github.com/prplkane/roundtable#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/prplkane/roundtable/issues"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,40 @@
1
+ // JSON CLI; the MCP adapter uses the same client directly.
2
+ import { readFile } from 'node:fs/promises';
3
+ import { BODY_LIMIT } from '../src/validate.js';
4
+ import { HELP, runAgent } from '../src/agent-client.js';
5
+ import { clientError } from '../src/local-http.js';
6
+
7
+ try {
8
+ const args = process.argv.slice(2), command = args.shift();
9
+ if (!command || ['help', '--help', '-h'].includes(command) || args.includes('--help')) { console.log(JSON.stringify(HELP)); }
10
+ else {
11
+ if (!Object.hasOwn(HELP.commands, command)) throw new Error('Unknown agent command. Use agent --help.');
12
+ const flags = {};
13
+ while (args.length) {
14
+ const key = args.shift();
15
+ if (key === '--refresh') { flags.refresh = true; continue; }
16
+ if (!['--port', '--id', '--input', '--seconds', '--after-round'].includes(key) || !args.length || Object.hasOwn(flags, key.slice(2))) throw new Error('Invalid or repeated option. Use agent --help.');
17
+ flags[key.slice(2)] = args.shift();
18
+ }
19
+ async function input(required = true) {
20
+ if (!flags.input) { if (!required) return {}; throw new Error('Supply --input file or --input - for JSON on stdin.'); }
21
+ let text;
22
+ if (flags.input === '-') {
23
+ const chunks = []; let size = 0;
24
+ for await (const chunk of process.stdin) { size += chunk.length; if (size > BODY_LIMIT) throw new Error('Input exceeds 256 KiB.'); chunks.push(chunk); }
25
+ text = Buffer.concat(chunks).toString('utf8');
26
+ } else {
27
+ const bytes = await readFile(flags.input);
28
+ if (bytes.length > BODY_LIMIT) throw new Error('Input exceeds 256 KiB.');
29
+ text = bytes.toString('utf8');
30
+ }
31
+ try { return JSON.parse(text.replace(/^\uFEFF/, '')); } catch { throw new Error('Input must contain valid JSON.'); }
32
+ }
33
+ const body = ['check', 'start', 'answer', 'hold', 'finish', 'stop', 'resume', 'verdict'].includes(command)
34
+ ? await input(['check', 'start', 'answer', 'hold'].includes(command)) : undefined;
35
+ console.log(JSON.stringify(await runAgent(command, flags, body)));
36
+ }
37
+ } catch (error) {
38
+ console.log(JSON.stringify(clientError(error)));
39
+ process.exitCode = 1;
40
+ }
@@ -0,0 +1,20 @@
1
+ // node scripts/export.mjs <slug> <target dir> (npm run export -- <slug> <target dir>)
2
+ // Writes <target>/<slug>.md, the decision record for a saved session, and
3
+ // refreshes the index (README.md, or DECISIONS.md when a README exists that roundtable did not
4
+ // write). Idempotent: re-exporting replaces both. A file in the way that roundtable did not
5
+ // write is refused, never replaced.
6
+ import { openDebateDir } from '../src/store.js';
7
+ import { exportDebate } from '../src/export.js';
8
+ import { dataDirectory } from '../src/history.js';
9
+
10
+ const [slug, target] = process.argv.slice(2);
11
+ if (!slug || !target) { console.error('usage: node scripts/export.mjs <slug> <target dir>'); process.exit(2); }
12
+ const debatesDir = dataDirectory();
13
+ const store = openDebateDir(debatesDir, slug, { create: false });
14
+ if (!store) { console.error(`no transcript for ${slug} under ${debatesDir}`); process.exit(1); }
15
+ try {
16
+ const warnings = [];
17
+ const rows = store.readTranscript({ onWarning: (w) => { warnings.push(w); console.warn(w.message); } });
18
+ const out = exportDebate(rows, slug, target, { forbid: [debatesDir], warnings });
19
+ console.log(`${out.path}\n${out.indexPath} (${out.meta.status}: ${out.meta.chosen})`);
20
+ } catch (e) { console.error(e.message); process.exit(1); }
@@ -0,0 +1,175 @@
1
+ // Dependency-free MCP stdio adapter. Shares the review client with the JSON CLI.
2
+ // No provider is called by initialize/tools/list/guide or by starting the local board.
3
+ import { VERSION } from '../src/version.js';
4
+ import { BODY_LIMIT, LIMITS } from '../src/validate.js';
5
+ import { runAgent } from '../src/agent-client.js';
6
+ import { clientError } from '../src/local-http.js';
7
+ import { ensureServer } from '../src/local-server.js';
8
+
9
+ const integer = ([minimum, maximum]) => ({ type: 'integer', minimum, maximum });
10
+ const object = (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
11
+ const id = { type: 'string', pattern: '^[a-z0-9-]+$', maxLength: 250, description: 'The exact review id returned by start.' };
12
+ const round = integer(LIMITS.critiqueRounds);
13
+ const text = (maxLength, description) => ({ type: 'string', maxLength, ...(description ? { description } : {}) });
14
+ const review = { type: 'object', required: ['fieldNotes', 'seats'], additionalProperties: false, properties: {
15
+ fieldNotes: { type: 'object', required: ['decision', 'options'], properties: {
16
+ decision: text(LIMITS.decision), options: { type: 'array', minItems: LIMITS.options[0], maxItems: LIMITS.options[1], items: text(LIMITS.option) },
17
+ constraints: text(LIMITS.field), known: text(LIMITS.field), assumed: text(LIMITS.field), reversibility: text(LIMITS.field), deadline: text(LIMITS.field),
18
+ }, additionalProperties: false },
19
+ seats: { type: 'array', minItems: LIMITS.seats[0], maxItems: LIMITS.seats[1], items: { type: 'object', required: ['vendor', 'lens'],
20
+ properties: { vendor: text(LIMITS.seatField), lens: text(LIMITS.seatField), model: text(LIMITS.seatField), effort: text(LIMITS.seatField) }, additionalProperties: true } },
21
+ chairman: { type: 'object', properties: { vendor: text(LIMITS.seatField), model: text(LIMITS.seatField), effort: text(LIMITS.seatField) }, additionalProperties: true },
22
+ options: { type: 'object', properties: {
23
+ critiqueRounds: { ...round, description: 'Default 1. Use 0 for a quick comparison; 2 for deeper examination within agreed limits. Consensus can stop early.' },
24
+ unattended: { type: 'boolean', description: 'True only when nobody will answer. Attended reviews auto-continue after the answer window.' },
25
+ questionWaitMs: integer(LIMITS.questionWaitMs),
26
+ autoContinueMs: integer(LIMITS.autoContinueMs),
27
+ budget: { type: 'object', additionalProperties: true, description: 'Per-provider caps, for example {"claude":{"tokens":160000}}. Check projected calls first.' },
28
+ }, additionalProperties: true },
29
+ } };
30
+ const descriptors = [
31
+ ['guide', 'Read the Roundtable workflow, request recipe and question/completion rules. No model calls.', object(), true],
32
+ ['status', 'Check provider setup, or read a review by id. Starts the local board if needed. refresh checks free provider status; no model call.', object({ id, refresh: { type: 'boolean' } }), true],
33
+ ['check', 'Validate a proposed review and estimate its calls/tokens before starting. Makes no model call.', object({ review }, ['review']), true],
34
+ ['start', 'Start the requested Roundtable review after check. Makes model calls within its limits. Returns an id, NOT a recommendation; keep calling wait through the chairman.', object({ review }, ['review']), false],
35
+ ['wait', 'Wait up to 25 seconds for questions or completion. A wait timeout is progress, not completion. After showing questions, pass afterRound to await the next step without repeating them.', object({ id, seconds: integer(LIMITS.waitSeconds), afterRound: round }, ['id']), true],
36
+ ['answer', 'Submit only the user\'s real answers to the current round; read status first. May resume model calls. Silence is handled by the timer, not blank answers.', object({ id, round,
37
+ answers: { type: 'array', maxItems: LIMITS.answers, items: object({ id: text(LIMITS.questionId), text: text(LIMITS.answer) }, ['id', 'text']) }, message: text(LIMITS.message) }, ['id', 'round']), false],
38
+ ['hold', 'Give the user another bounded answer window when they need more time. No model call. Never use as an automatic keepalive.', object({ id, round }, ['id', 'round']), false],
39
+ ['finish', 'On the user\'s request, end discussion and call the chairman with collected replies. Keep waiting for the verdict.', object({ id, message: text(LIMITS.message) }, ['id']), false],
40
+ ['stop', 'Cancel this review, including an in-flight chairman, when the user asks to stop. Does not synthesize a recommendation.', object({ id, message: text(LIMITS.message) }, ['id']), false],
41
+ ['resume', 'Resume an interrupted review when requested. Can make model calls; never automatically retry an uncertain write.', object({ id, message: text(LIMITS.message) }, ['id']), false],
42
+ ['verdict', 'Request a missing or failed chairman verdict when authorized and available in actions. Can make a model call; wait for completion.', object({ id }, ['id']), false],
43
+ ];
44
+ const TOOLS = descriptors.map(([command, description, inputSchema, readOnlyHint]) => ({
45
+ name: `roundtable_${command}`, description, inputSchema,
46
+ annotations: { readOnlyHint, destructiveHint: false, idempotentHint: readOnlyHint, openWorldHint: !['guide', 'wait', 'hold', 'stop'].includes(command) },
47
+ }));
48
+
49
+ function validate(value, schema, field = 'arguments') {
50
+ const kind = schema.type;
51
+ if (kind === 'object') {
52
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${field} must be an object.`);
53
+ for (const key of schema.required || []) if (!Object.hasOwn(value, key)) throw new Error(`${field}.${key} is required.`);
54
+ for (const [key, v] of Object.entries(value)) {
55
+ if (Object.hasOwn(schema.properties || {}, key)) validate(v, schema.properties[key], `${field}.${key}`);
56
+ else if (schema.additionalProperties === false) throw new Error(`Unknown field ${field}.${key}.`);
57
+ }
58
+ } else if (kind === 'array') {
59
+ if (!Array.isArray(value) || value.length < (schema.minItems || 0) || value.length > (schema.maxItems ?? Infinity)) throw new Error(`${field} has an invalid list length.`);
60
+ value.forEach((v, i) => validate(v, schema.items, `${field}[${i}]`));
61
+ } else if (kind === 'string') {
62
+ if (typeof value !== 'string' || value.length > (schema.maxLength ?? Infinity) || schema.pattern && !new RegExp(schema.pattern).test(value)) throw new Error(`${field} is invalid text.`);
63
+ } else if (kind === 'integer') {
64
+ if (!Number.isInteger(value) || value < schema.minimum || value > schema.maximum) throw new Error(`${field} must be an integer from ${schema.minimum} to ${schema.maximum}.`);
65
+ } else if (typeof value !== kind) throw new Error(`${field} must be ${kind}.`);
66
+ }
67
+
68
+ function options(args) {
69
+ const out = { fake: false, start: true };
70
+ while (args.length) {
71
+ const arg = args.shift();
72
+ if (arg === '--fake') out.fake = true;
73
+ else if (arg === '--no-start') out.start = false;
74
+ else if (arg === '--port' && args.length && out.port == null) out.port = Number(args.shift());
75
+ else throw new Error('Use roundtable mcp [--port N] [--fake] [--no-start].');
76
+ }
77
+ out.port ??= out.fake ? 4748 : 4747;
78
+ if (!Number.isInteger(out.port) || out.port < LIMITS.port[0] || out.port > LIMITS.port[1]) throw new Error('Invalid loopback port.');
79
+ return out;
80
+ }
81
+
82
+ async function serve(config) {
83
+ let starting;
84
+ async function ready(signal) {
85
+ signal.throwIfAborted();
86
+ // Share startup across simultaneous tools; each cancelled caller stops before dispatch.
87
+ starting ??= ensureServer(config).finally(() => { starting = null; });
88
+ let cancel;
89
+ const cancelled = new Promise((_, reject) => { cancel = () => reject(signal.reason); signal.addEventListener('abort', cancel, { once: true }); });
90
+ try { return await Promise.race([starting, cancelled]); }
91
+ finally { signal.removeEventListener('abort', cancel); }
92
+ }
93
+ async function call(name, input, signal) {
94
+ const tool = TOOLS.find((t) => t.name === name);
95
+ if (!tool) throw new Error('Unknown Roundtable tool.');
96
+ validate(input, tool.inputSchema);
97
+ if (Buffer.byteLength(JSON.stringify(input)) > BODY_LIMIT) throw new Error('Tool arguments exceed 256 KiB.');
98
+ const command = name.slice('roundtable_'.length);
99
+ if (command !== 'guide') await ready(signal);
100
+ if (signal.aborted) throw new Error('Tool request cancelled before dispatch.');
101
+ const flags = { port: config.port, id: input.id, refresh: input.refresh, seconds: input.seconds, 'after-round': input.afterRound };
102
+ let body;
103
+ if (['start', 'check'].includes(command)) body = input.review;
104
+ else if (['answer', 'hold', 'finish', 'stop', 'resume', 'verdict'].includes(command)) { body = { ...input }; delete body.id; }
105
+ const result = { service: 'roundtable', transport: 'mcp', ok: true, ...await runAgent(command, flags, body, signal) };
106
+ if (result.id) {
107
+ if (['ask_user', 'await_user'].includes(result.nextAction?.type)) {
108
+ result.nextAction.tool = 'roundtable_answer';
109
+ result.nextAction.arguments = { id: result.id, ...result.nextAction.answerTemplate };
110
+ result.nextAction.wait = { tool: 'roundtable_wait', arguments: { id: result.id, afterRound: result.gate.round } };
111
+ result.nextAction.extend = { tool: 'roundtable_hold', arguments: { id: result.id, round: result.gate.round } };
112
+ } else if (!result.nextAction || result.nextAction.type === 'wait') result.nextAction = { type: 'wait', tool: 'roundtable_wait', arguments: { id: result.id }, instruction: 'Keep waiting through discussion and chairman synthesis. Only done with verdict.status ready is a completed recommendation.' };
113
+ result.completed = result.status === 'done' && result.verdict?.status === 'ready';
114
+ }
115
+ return result;
116
+ }
117
+
118
+ let initialized = false, input = Buffer.alloc(0), closing = false;
119
+ const requests = new Map();
120
+ const send = (value) => { if (!closing) process.stdout.write(`${JSON.stringify(value)}\n`); };
121
+ const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
122
+ async function handle(message) {
123
+ if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string' || message.id != null && !['number', 'string'].includes(typeof message.id)) { error(null, -32600, 'Invalid JSON-RPC request.'); return; }
124
+ if (!Object.hasOwn(message, 'id')) {
125
+ if (message.method === 'notifications/cancelled') requests.get(message.params?.requestId)?.abort();
126
+ return;
127
+ }
128
+ const { id, method, params = {} } = message;
129
+ if (requests.has(id)) { error(id, -32600, 'Request id is already in flight.'); return; }
130
+ if (method === 'initialize') {
131
+ if (initialized) { error(id, -32600, 'Already initialized.'); return; }
132
+ initialized = true;
133
+ send({ jsonrpc: '2.0', id, result: { protocolVersion: ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'].includes(params?.protocolVersion) ? params.protocolVersion : '2025-11-25',
134
+ capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'roundtable', version: VERSION },
135
+ instructions: 'Use roundtable_guide and status before check/start. Keep waiting until the chairman finishes. Present ask_user questions with the host input tool or chat; silence auto-continues without answers. Never replace Roundtable with your own agents or accept a recommendation for the user.' } }); return;
136
+ }
137
+ if (method === 'ping') { send({ jsonrpc: '2.0', id, result: {} }); return; }
138
+ if (!initialized) { error(id, -32000, 'Initialize first.'); return; }
139
+ if (method === 'tools/list') { send({ jsonrpc: '2.0', id, result: { tools: TOOLS } }); return; }
140
+ if (method !== 'tools/call') { error(id, -32601, 'Method not found.'); return; }
141
+ if (requests.size >= 16) { error(id, -32000, 'Too many concurrent tool calls.'); return; }
142
+ const controller = new AbortController(); requests.set(id, controller);
143
+ try {
144
+ let result;
145
+ try { result = await call(params?.name, params?.arguments ?? {}, controller.signal); }
146
+ catch (e) { result = { service: 'roundtable', transport: 'mcp', ...clientError(e) }; }
147
+ if (!controller.signal.aborted) send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result, isError: result.ok === false } });
148
+ } finally { requests.delete(id); }
149
+ }
150
+ const close = () => { closing = true; for (const c of requests.values()) c.abort(); };
151
+ process.stdin.on('data', (chunk) => {
152
+ input = Buffer.concat([input, chunk]);
153
+ let end;
154
+ while ((end = input.indexOf(10)) >= 0) {
155
+ const line = input.subarray(0, end); input = input.subarray(end + 1);
156
+ if (line.length > BODY_LIMIT + 4096) { error(null, -32600, 'Message exceeds size limit.'); close(); process.exitCode = 1; process.stdin.destroy(); return; }
157
+ if (!line.toString('utf8').trim()) continue;
158
+ let parsed;
159
+ try { parsed = JSON.parse(line.toString('utf8')); } catch { error(null, -32700, 'Invalid JSON.'); continue; }
160
+ void handle(parsed).catch(() => error(parsed?.id ?? null, -32603, 'Internal protocol error.'));
161
+ }
162
+ if (input.length > BODY_LIMIT + 4096) { error(null, -32600, 'Message exceeds size limit.'); close(); process.exitCode = 1; process.stdin.destroy(); }
163
+ });
164
+ process.stdin.on('end', close);
165
+ process.stdout.on('error', close);
166
+ process.on('SIGINT', () => { close(); process.exit(0); });
167
+ process.on('SIGTERM', () => { close(); process.exit(0); });
168
+ }
169
+
170
+ if (process.argv.slice(2).some((a) => ['--help', '-h', 'help'].includes(a))) {
171
+ console.log('roundtable mcp [--port N] [--fake] [--no-start]\nMCP over newline-delimited JSON-RPC on stdio. Normally starts a hidden local board when needed. --fake uses scripted providers and port 4748. --no-start requires an existing board. No model call is made until a review action requests it.');
172
+ } else {
173
+ try { await serve(options(process.argv.slice(2))); }
174
+ catch (e) { console.error(e.message); process.exitCode = 1; }
175
+ }
@@ -0,0 +1,70 @@
1
+ // Drive one debate headless against a running server and print what the strip would show.
2
+ // Gates are answered after a short pause (a note for round 0 if --note is given), so a real
3
+ // multi-seat run can be recorded without the browser. Usage:
4
+ // node scripts/run-debate.mjs --port 4747 --token <hex> --rounds 1 --note "..." < debate.json
5
+ // The token is on the server's startup line; ROUNDTABLE_TOKEN works too.
6
+ // stdin: the same JSON body the setup screen posts ({fieldNotes, seats, chairman, options}).
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { dataDirectory } from '../src/history.js';
10
+
11
+ const arg = (name, dflt) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : dflt; };
12
+ const PORT = Number(arg('port', 4747));
13
+ const NOTE = arg('note', '');
14
+ const PAUSE = Number(arg('pause', 3000));
15
+ const base = `http://127.0.0.1:${PORT}`;
16
+ const SEND = { 'content-type': 'application/json', 'x-roundtable-token': arg('token', process.env.ROUNDTABLE_TOKEN || '') };
17
+
18
+ const body = JSON.parse(readFileSync(0, 'utf8'));
19
+ if (arg('rounds')) body.options = { ...(body.options || {}), critiqueRounds: Number(arg('rounds')) };
20
+
21
+ const res = await fetch(`${base}/api/debates`, { method: 'POST', headers: SEND, body: JSON.stringify(body) });
22
+ const out = await res.json();
23
+ if (!res.ok) { console.error('refused:', out.error); process.exit(1); }
24
+ const slug = out.slug;
25
+ console.log(`debate ${slug}`);
26
+
27
+ const t0 = Date.now();
28
+ const stamp = () => `${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s`;
29
+ const usage = [];
30
+ const calib = [];
31
+ const stream = await fetch(`${base}/api/debates/${slug}/events`);
32
+ const reader = stream.body.getReader();
33
+ const dec = new TextDecoder();
34
+ let buf = '';
35
+ let done = false;
36
+ while (!done) {
37
+ const { value, done: end } = await reader.read();
38
+ if (end) break;
39
+ buf += dec.decode(value, { stream: true });
40
+ let i;
41
+ while ((i = buf.indexOf('\n\n')) >= 0) {
42
+ const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
43
+ const line = chunk.split('\n').find((l) => l.startsWith('data: '));
44
+ if (!line) continue;
45
+ const e = JSON.parse(line.slice(6));
46
+ switch (e.type) {
47
+ case 'started': console.log(stamp(), 'seats:', e.seats.map((s) => `${s.label}/${s.lens}${s.model ? '/' + s.model : ''}${s.effort ? '/' + s.effort : ''}`).join(', ')); break;
48
+ case 'attempt': if (e.usage) usage.push(e); console.log(stamp(), ` call ${e.kind} r${e.round} ${e.seat.label || e.seat.vendor}: ${e.usage ? `in ${e.usage.input} cache ${e.usage.cacheRead + e.usage.cacheWrite} out ${e.usage.output}${e.usage.cost != null ? ' $' + e.usage.cost.toFixed(2) : ''}` : 'usage unknown'}`); break;
49
+ case 'reply': console.log(stamp(), `r${e.round} ${e.seat.label} (${e.seat.lens}) -> ${e.stanceKey}${e.question ? ' | Q: ' + e.question : ''}${e.failed ? ' | FAILED ' + e.errors.join('; ') : ''}`); break;
50
+ case 'quota': console.log(stamp(), `quota ${e.vendor}:`, e.quota.windows.map((w) => `${w.label} ${(w.used * 100).toFixed(1)}%`).join(' ')); break;
51
+ case 'calibration': if (e.round != null) { calib.push(e); console.log(stamp(), `calibration ${e.key} round ${e.round}: ${e.tokens} tokens ->`, e.deltas.filter((d) => d.valid).map((d) => `${d.label} +${(d.delta * 100).toFixed(2)}%`).join(' ') || 'no valid delta'); } break;
52
+ case 'gate': {
53
+ console.log(stamp(), `gate ${e.reason} (${e.mode})${e.stop ? ' stop=' + e.stop : ''}${e.questions.length ? ' questions: ' + e.questions.map((q) => q.lens + ': ' + q.question).join(' | ') : ''}`);
54
+ setTimeout(() => fetch(`${base}/api/debates/${slug}/continue`, { method: 'POST', headers: SEND, body: JSON.stringify({ message: e.round === 0 ? NOTE : '' }) }), PAUSE);
55
+ break;
56
+ }
57
+ case 'reopen': console.log(stamp(), 'reopened after a note at a unanimous round 0'); break;
58
+ case 'stopped': console.log(stamp(), `stopped: ${e.reason} after ${e.rounds} rounds`); break;
59
+ case 'verdict': console.log(stamp(), `verdict by ${e.seat?.vendor || 'nobody'}${e.seat?.model ? '/' + e.seat.model : ''}`); break;
60
+ case 'error': console.log(stamp(), 'ERROR', e.message); done = true; break;
61
+ case 'done': done = true; break;
62
+ }
63
+ }
64
+ }
65
+ const per = {};
66
+ for (const e of usage) { const k = e.seat.label ? `${e.seat.label} (${e.seat.lens})` : 'chairman'; const u = (per[k] ??= { in: 0, cache: 0, out: 0, cost: 0 }); u.in += e.usage.input; u.cache += e.usage.cacheRead + e.usage.cacheWrite; u.out += e.usage.output; u.cost += e.usage.cost || 0; }
67
+ console.log('\nper seat:'); for (const [k, u] of Object.entries(per)) console.log(` ${k}: in ${u.in} cache ${u.cache} out ${u.out}${u.cost ? ' $' + u.cost.toFixed(2) : ''}`);
68
+ const f = path.join(dataDirectory(), '_calibration.json');
69
+ if (existsSync(f)) console.log('\ncalibration file:', readFileSync(f, 'utf8'));
70
+ process.exit(0);
@@ -0,0 +1,28 @@
1
+ import { inspectServer, ensureServer, stopServer } from '../src/local-server.js';
2
+ import { clientError } from '../src/local-http.js';
3
+ import { LIMITS } from '../src/validate.js';
4
+
5
+ const args = process.argv.slice(2), command = args.shift();
6
+ if (!command || ['help', '--help', '-h'].includes(command) || args.includes('--help')) {
7
+ console.log('roundtable server status|start|stop|restart [--port N] [--fake]\nManage the hidden local board. Stop/restart refuses active reviews and never cancels them. Use the same data directory and mode as the running board.');
8
+ } else {
9
+ try {
10
+ if (!['status', 'start', 'stop', 'restart'].includes(command)) throw new Error('Unknown server command. Use roundtable server --help.');
11
+ const config = { fake: false };
12
+ while (args.length) {
13
+ const arg = args.shift();
14
+ if (arg === '--fake' && !config.fake) config.fake = true;
15
+ else if (arg === '--port' && args.length && config.port == null) config.port = Number(args.shift());
16
+ else throw new Error('Invalid or repeated server option. Use roundtable server --help.');
17
+ }
18
+ config.port ??= config.fake ? 4748 : 4747;
19
+ if (!Number.isInteger(config.port) || config.port < LIMITS.port[0] || config.port > LIMITS.port[1]) throw new Error('Invalid loopback port.');
20
+ let result;
21
+ if (command === 'status') result = { ok: true, server: await inspectServer(config, { allowOlder: true }) };
22
+ else {
23
+ if (['stop', 'restart'].includes(command)) result = await stopServer(config);
24
+ if (['start', 'restart'].includes(command)) result = { ok: true, server: await ensureServer(config) };
25
+ }
26
+ console.log(JSON.stringify(result));
27
+ } catch (e) { console.log(JSON.stringify(clientError(e))); process.exitCode = 1; }
28
+ }
@@ -0,0 +1,182 @@
1
+ // Explicit, user-scoped host setup. No install hook, vendor CLI or model invocation.
2
+ import { readFile, writeFile, mkdir, rename, unlink, lstat, open, access, constants } from 'node:fs/promises';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { MCP_BEGIN as BEGIN, MCP_END as END, tomlConnection, replaceTomlConnection } from '../src/setup-toml.js';
8
+
9
+ const ROOT = fileURLToPath(new URL('../', import.meta.url));
10
+ const HELP = `roundtable setup --client codex|claude|agy|all [--check|--remove] [--port N] [--fake|--live] [--home directory]
11
+
12
+ Connect the installed Roundtable MCP tools and skill for all projects of the selected assistant.
13
+ Run once after a global npm install. Restart the assistant or reload its MCP connections afterward.
14
+ Run again after an update to refresh the skill and connection. --check inspects without changing files.
15
+ --remove removes only this setup's unchanged entries. User edits and unrelated settings are preserved.
16
+ --fake selects demo mode; --live selects live mode. Updates preserve the saved mode and port
17
+ unless explicitly changed. A new connection defaults to live port 4747 (demo: 4748).
18
+ --home selects an isolated user home for testing; normally the current user's home is used.
19
+ CODEX_HOME and CLAUDE_CONFIG_DIR are honored unless --home is supplied. No model call is made.
20
+ `;
21
+ const hash = (s) => createHash('sha256').update(s).digest('hex');
22
+ const plain = (v) => v && typeof v === 'object' && !Array.isArray(v);
23
+
24
+ function parse(args) {
25
+ const out = { clients: [], home: os.homedir(), explicitHome: false, check: false, remove: false };
26
+ while (args.length) {
27
+ const key = args.shift();
28
+ if (key === '--client' && args.length) out.clients.push(args.shift());
29
+ else if (key === '--home' && args.length && !out.explicitHome) { out.home = path.resolve(args.shift()); out.explicitHome = true; }
30
+ else if (key === '--port' && args.length && out.port == null) out.port = Number(args.shift());
31
+ else if (['--fake', '--live'].includes(key) && out.fake == null) out.fake = key === '--fake';
32
+ else if (['--check', '--remove'].includes(key) && !out[key.slice(2)]) out[key.slice(2)] = true;
33
+ else throw new Error('Invalid or repeated setup option. Use roundtable setup --help.');
34
+ }
35
+ if (!out.clients.length || out.clients.some((c) => !['codex', 'claude', 'agy', 'all'].includes(c))) throw new Error('Choose --client codex, claude, agy or all.');
36
+ out.clients = [...new Set(out.clients.includes('all') ? ['codex', 'claude', 'agy'] : out.clients)];
37
+ if (out.check && out.remove) throw new Error('Choose either --check or --remove.');
38
+ if (out.port != null && (!Number.isInteger(out.port) || out.port < 1 || out.port > 65535)) throw new Error('Port must be from 1 to 65535.');
39
+ return out;
40
+ }
41
+
42
+ async function readTarget(file) {
43
+ try {
44
+ const stat = await lstat(file);
45
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing to replace a linked or non-file configuration: ${file}`);
46
+ return { text: await readFile(file, 'utf8'), mode: stat.mode & 0o777 };
47
+ } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
48
+ }
49
+ function json(raw, label) {
50
+ let value;
51
+ try { value = raw ? JSON.parse(raw.text.replace(/^\uFEFF/, '')) : {}; } catch { throw new Error(`${label} contains invalid JSON; it was not changed.`); }
52
+ if (!plain(value)) throw new Error(`${label} must contain a JSON object; it was not changed.`);
53
+ return value;
54
+ }
55
+ const same = (a, b) => a?.text === b?.text;
56
+ async function writeTarget(file, before, after) {
57
+ if (!same(before, await readTarget(file))) throw new Error(`Configuration changed during setup; retry after the other editor finishes: ${file}`);
58
+ if (after == null) { if (before) await unlink(file); return; }
59
+ if (before?.text === after.text) return;
60
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
61
+ const tmp = `${file}.roundtable-${randomUUID()}.tmp`;
62
+ try {
63
+ await writeFile(tmp, after.text, { flag: 'wx', mode: after.mode ?? before?.mode ?? 0o600 });
64
+ if (!same(before, await readTarget(file))) throw new Error(`Configuration changed during setup; it was not replaced: ${file}`);
65
+ await rename(tmp, file);
66
+ }
67
+ finally { await unlink(tmp).catch((e) => { if (e.code !== 'ENOENT') throw e; }); }
68
+ }
69
+
70
+ function hostFiles(client, opts) {
71
+ const h = opts.home;
72
+ if (client === 'codex') {
73
+ const codex = !opts.explicitHome && process.env.CODEX_HOME ? path.resolve(process.env.CODEX_HOME) : path.join(h, '.codex');
74
+ return { config: path.join(codex, 'config.toml'), skill: path.join(codex, 'skills', 'roundtable', 'SKILL.md'), kind: 'toml' };
75
+ }
76
+ if (client === 'claude') {
77
+ const custom = !opts.explicitHome && process.env.CLAUDE_CONFIG_DIR ? path.resolve(process.env.CLAUDE_CONFIG_DIR) : null;
78
+ return { config: custom ? path.join(custom, '.claude.json') : path.join(h, '.claude.json'),
79
+ skill: path.join(custom || path.join(h, '.claude'), 'skills', 'roundtable', 'SKILL.md'), kind: 'json' };
80
+ }
81
+ return { config: path.join(h, '.gemini', 'config', 'mcp_config.json'), skill: path.join(h, '.gemini', 'config', 'skills', 'roundtable', 'SKILL.md'), kind: 'json' };
82
+ }
83
+
84
+ function configEntry(raw, kind) {
85
+ if (kind === 'json') {
86
+ const parsed = json(raw, 'Assistant configuration');
87
+ if (parsed.mcpServers != null && !plain(parsed.mcpServers)) throw new Error('mcpServers must be an object; configuration was not changed.');
88
+ return { parsed, entry: parsed.mcpServers?.roundtable === undefined ? null : JSON.stringify(parsed.mcpServers.roundtable) };
89
+ }
90
+ return tomlConnection(raw?.text || '');
91
+ }
92
+ function updateConfig(current, kind, entry) {
93
+ if (kind === 'json') {
94
+ const parsed = structuredClone(current.parsed);
95
+ if (entry == null) { if (parsed.mcpServers) { delete parsed.mcpServers.roundtable; if (!Object.keys(parsed.mcpServers).length) delete parsed.mcpServers; } }
96
+ else { parsed.mcpServers ??= {}; parsed.mcpServers.roundtable = JSON.parse(entry); }
97
+ return `${JSON.stringify(parsed, null, 2)}\n`;
98
+ }
99
+ return replaceTomlConnection(current, entry);
100
+ }
101
+
102
+ async function executableExists(command) {
103
+ if (!command || !path.isAbsolute(command)) return false;
104
+ try {
105
+ await access(command, constants.X_OK);
106
+ const stat = await lstat(command);
107
+ return stat.isFile() || stat.isSymbolicLink();
108
+ } catch { return false; }
109
+ }
110
+
111
+ async function setup(opts) {
112
+ const receiptFile = path.join(opts.home, '.roundtable', 'integrations.json');
113
+ let lock;
114
+ if (!opts.check) {
115
+ await mkdir(path.dirname(receiptFile), { recursive: true, mode: 0o700 });
116
+ try { lock = await open(`${receiptFile}.lock`, 'wx', 0o600); }
117
+ catch (e) { if (e.code === 'EEXIST') throw new Error('Another setup holds the integration lock. If it crashed, remove integrations.json.lock before retrying.'); throw e; }
118
+ }
119
+ try {
120
+ const oldReceipt = await readTarget(receiptFile), receipt = json(oldReceipt, 'Roundtable integration receipt');
121
+ receipt.clients ??= {};
122
+ if (!plain(receipt.clients)) throw new Error('Invalid integration receipt; no configuration changed.');
123
+ const skillText = await readFile(path.join(ROOT, 'skills', 'roundtable', 'SKILL.md'), 'utf8');
124
+ const command = process.execPath;
125
+ const changes = [], results = [];
126
+ for (const client of opts.clients) {
127
+ const files = hostFiles(client, opts);
128
+ const [configRaw, skillRaw] = await Promise.all([readTarget(files.config), readTarget(files.skill)]);
129
+ const current = configEntry(configRaw, files.kind), saved = receipt.clients[client];
130
+ const sameLocation = saved && saved.config === files.config && saved.skill === files.skill;
131
+ // A profile override selects a different host, not permission to overwrite the old one.
132
+ if (saved && !sameLocation) throw new Error(`${client}: the host profile moved. Remove the old connection using its original profile before connecting this one.`);
133
+ const configMatches = sameLocation && current.entry != null && hash(current.entry) === saved.configHash;
134
+ const skillMatches = sameLocation && skillRaw != null && hash(skillRaw.text) === saved.skillHash;
135
+ const intact = configMatches && skillMatches;
136
+ if (opts.check) {
137
+ let scriptExists = false, nodeExists = false;
138
+ if (saved?.script) scriptExists = !!await readTarget(saved.script);
139
+ // Old receipts predate the command field; read it only from an unchanged owned entry.
140
+ const node = saved?.command || (configMatches ? files.kind === 'json' ? current.parsed.mcpServers.roundtable.command
141
+ : JSON.parse(/^command = (.+)$/m.exec(current.entry)?.[1] || 'null') : null);
142
+ nodeExists = await executableExists(node);
143
+ results.push({ client, connected: !!intact && scriptExists && nodeExists, config: files.config, skill: files.skill,
144
+ ...(intact && scriptExists && nodeExists ? {} : { issue: !nodeExists && saved ? 'The configured Node executable is missing or not executable. Rerun setup with a working Node installation.'
145
+ : 'Connection missing, edited, or installed script moved. Rerun setup to repair missing files; reconcile edited files first.' }) });
146
+ continue;
147
+ }
148
+ if (saved && (current.entry != null && !configMatches || skillRaw != null && !skillMatches) || !saved && (current.entry != null || skillRaw)) throw new Error(`${client}: existing Roundtable settings or skill were edited or are unmanaged. They were preserved; reconcile them before setup.`);
149
+ if (opts.remove && !saved) { results.push({ client, removed: false, reason: 'No managed connection.' }); continue; }
150
+ const fake = opts.fake ?? (saved?.mode === 'demo');
151
+ const port = opts.port ?? (opts.fake == null ? saved?.port : null) ?? (fake ? 4748 : 4747);
152
+ const args = [path.join(ROOT, 'scripts', 'mcp.mjs'), '--port', String(port), ...(fake ? ['--fake'] : [])];
153
+ const entry = opts.remove ? null : files.kind === 'json' ? JSON.stringify({ command, args })
154
+ : `${BEGIN}\n[mcp_servers.roundtable]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(args)}\n${END}`;
155
+ if (!opts.remove || current.entry != null) changes.push({ file: files.config, before: configRaw, after: { text: updateConfig(current, files.kind, entry) } });
156
+ changes.push({ file: files.skill, before: skillRaw, after: opts.remove ? null : { text: skillText } });
157
+ if (opts.remove) delete receipt.clients[client];
158
+ else receipt.clients[client] = { ...files, configHash: hash(entry), skillHash: hash(skillText), command, script: args[0], port, mode: fake ? 'demo' : 'live' };
159
+ results.push({ client, ...(opts.remove ? { removed: true } : { connected: true }), config: files.config, skill: files.skill });
160
+ }
161
+ if (!opts.check) {
162
+ changes.push({ file: receiptFile, before: oldReceipt, after: { text: `${JSON.stringify(receipt, null, 2)}\n` } });
163
+ const applied = [];
164
+ try { for (const change of changes) { await writeTarget(change.file, change.before, change.after); applied.push(change); } }
165
+ catch (e) {
166
+ let rollbackFailed = false;
167
+ for (const c of applied.reverse()) { try { await writeTarget(c.file, c.after, c.before); } catch { rollbackFailed = true; } }
168
+ throw new Error(`${e.message}${rollbackFailed ? ' Some files changed concurrently; inspect the connection before retrying.' : ' Previous file contents restored.'}`);
169
+ }
170
+ }
171
+ return { ok: !opts.check || results.every((r) => r.connected), clients: results,
172
+ next: opts.check ? 'A connection check makes no model call. Use roundtable_status in the assistant to verify tool availability.'
173
+ : opts.remove ? 'Restart the assistant or reload its MCP connections to remove the tools.'
174
+ : 'Restart the assistant or reload its MCP connections. From any project, ask it to use Roundtable; it should call roundtable_guide, status, check, start and wait for the chairman. Connecting a host does not authorize using it as a review provider.' };
175
+ } finally { if (lock) { await lock.close(); await unlink(`${receiptFile}.lock`); } }
176
+ }
177
+
178
+ if (!process.argv[2] || process.argv.slice(2).some((a) => ['--help', '-h', 'help'].includes(a))) console.log(HELP);
179
+ else {
180
+ try { const result = await setup(parse(process.argv.slice(2))); console.log(JSON.stringify(result)); if (!result.ok) process.exitCode = 1; }
181
+ catch (e) { console.log(JSON.stringify({ ok: false, error: e.message })); process.exitCode = 1; }
182
+ }