@prplkane/roundtable 0.0.0-stage → 0.6.4

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 +529 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -3
  4. package/SECURITY.md +299 -0
  5. package/docs/workflow.md +1344 -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 +57 -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 +101 -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 +110 -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 +928 -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 +77 -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,331 @@
1
+ // The quota picture: how much a table is likely to draw from each vendor's pool, and how the
2
+ // measured windows move per round. Pure, so it runs in the browser and in tests.
3
+ //
4
+ // Baselines come from this machine's own transcripts (tokens per call, fresh round-0 calls and
5
+ // resumed later calls separately). Effort multipliers are guesses until measured deltas replace
6
+ // them; they live here and nowhere else.
7
+ import { quotaGroup } from './adapters/models.js';
8
+
9
+ export const EFFORT_MULTIPLIER = { low: 0.7, medium: 1, high: 1.4, xhigh: 1.8, ultra: 2.2, max: 2.6 };
10
+
11
+ export function unknownUsageMessage(cause) {
12
+ const first = cause === 'timeout' ? 'An earlier call timed out before reporting token usage.'
13
+ : cause === 'network' ? 'An earlier call lost its connection before reporting token usage.'
14
+ : ['killed', 'interrupted'].includes(cause) ? 'An earlier call was interrupted before reporting token usage.'
15
+ : 'An earlier call did not report complete token usage.';
16
+ return `${first} Further calls are blocked because spending is unknown. Check the earlier failure and provider usage before starting more work.`;
17
+ }
18
+
19
+ // How much of a vendor's pool one token of a model draws, relative to the vendor's heaviest
20
+ // model (1). Guesses from list-price ratios: Opus : Sonnet : Haiku about 1 : 0.2 : 0.05;
21
+ // Gemini Pro-class : Flash about 1 : 0.2. An id that matches nothing weighs 1 (the safe side).
22
+ // A per-model calibration, once measured, replaces the weight (projectedWindows).
23
+ export const MODEL_WEIGHT = [
24
+ [/haiku/i, 0.05], [/sonnet/i, 0.2], [/opus/i, 1],
25
+ [/flash/i, 0.2], [/argon|pro\b/i, 1], [/gpt-oss/i, 0.1],
26
+ [/mini|nano/i, 0.2],
27
+ ];
28
+ export function modelWeight(model) {
29
+ if (!model) return 1;
30
+ for (const [re, w] of MODEL_WEIGHT) if (re.test(model)) return w;
31
+ return 1;
32
+ }
33
+ // Used before any transcript exists. Taken from real debates; replaced by this machine's own medians as soon as it has some.
34
+ export const FALLBACK = {
35
+ claude: { fresh: { input: 13000, output: 1500 }, resumed: { input: 9500, output: 1200 } },
36
+ codex: { fresh: { input: 18000, output: 1200 }, resumed: { input: 34000, output: 1000 } },
37
+ agy: { fresh: { input: 13000, output: 1300 }, resumed: { input: 30000, output: 1200 } },
38
+ };
39
+ const GENERIC = { fresh: { input: 15000, output: 1300 }, resumed: { input: 25000, output: 1100 } };
40
+ const CHAIRMAN_INPUT_FACTOR = 1.6; // the chairman reads the whole transcript in one fresh call
41
+
42
+ // Setup has no source bundle yet. Reserve an allowance per discussion/verdict call; after
43
+ // research, use the saved bundle's UTF-8 size at three bytes per estimated input token.
44
+ // This adds conservatism to historical baselines, not a tokenizer or a billing ceiling.
45
+ export const RESEARCH_INPUT_RESERVE = 12000;
46
+ export function researchInputTokens(sources = null) {
47
+ if (!Array.isArray(sources)) return RESEARCH_INPUT_RESERVE;
48
+ if (!sources.length) return 0;
49
+ const records = sources.map(({ id, url, title, excerpt, publishedAt, gatheredAt }) =>
50
+ JSON.stringify({ kind: 'web_source', id, url, title, publishedAt, gatheredAt, text: excerpt })
51
+ .replace(/[\u0085\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`));
52
+ return Math.ceil(new TextEncoder().encode(records.join('\n')).length / 3);
53
+ }
54
+ const evidenceCost = (cost, baseline, tokens) => cost == null ? 0 : cost * 0.8 * tokens / baseline.input;
55
+
56
+ // median for dollar figures, kept to the cent
57
+ function median100(xs) { return Math.round(median(xs.map((x) => x * 100))) / 100; }
58
+
59
+ function median(xs) {
60
+ if (!xs.length) return 0;
61
+ const s = [...xs].sort((a, b) => a - b);
62
+ const m = Math.floor(s.length / 2);
63
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
64
+ }
65
+
66
+ // Transcript rows (any number of debates) -> per-vendor medians per call.
67
+ export function baselinesFrom(rows) {
68
+ const acc = {};
69
+ for (const r of rows || []) {
70
+ if (r.kind === 'research') continue; // web evidence gathering has a different context size
71
+ if (!r.usage || !r.seat?.vendor) continue;
72
+ if (r.type !== 'reply' && r.type !== 'verdict' && r.type !== 'attempt') continue;
73
+ // a call the server refused before the model ran (an endpoint's 429) reports zero tokens: known
74
+ // spending for the caps, but no sample of what a call costs
75
+ if (!((r.usage.input || 0) + (r.usage.cacheRead || 0) + (r.usage.cacheWrite || 0) + (r.usage.output || 0))) continue;
76
+ if (typeof r.sessionId === 'string' && r.sessionId.startsWith('fake-')) continue; // a scripted seat
77
+ // Rows since 0.2.0 say whether the call resumed a session; older rows are fresh at round 0 only.
78
+ const kind = r.type === 'verdict' || r.kind === 'verdict' || r.round === 0 || r.fresh === true ? 'fresh' : 'resumed';
79
+ const v = (acc[r.seat.vendor] ??= { fresh: { input: [], output: [], cost: [] }, resumed: { input: [], output: [], cost: [] } });
80
+ v[kind].input.push((r.usage.input || 0) + (r.usage.cacheRead || 0) + (r.usage.cacheWrite || 0));
81
+ v[kind].output.push((r.usage.output || 0));
82
+ // Cost in the vendor's own currency where the CLI reports it (claude: total_cost_usd per
83
+ // call, exact and without lag). Kept per model too, since cost is where models differ most.
84
+ if (typeof r.usage.cost === 'number') {
85
+ v[kind].cost.push(r.usage.cost);
86
+ const m = (acc[`${r.seat.vendor}:${r.seat.model || 'default'}`] ??= { fresh: { input: [], output: [], cost: [] }, resumed: { input: [], output: [], cost: [] } });
87
+ m[kind].cost.push(r.usage.cost);
88
+ }
89
+ }
90
+ const out = {};
91
+ for (const [vendor, v] of Object.entries(acc)) {
92
+ out[vendor] = {};
93
+ for (const kind of ['fresh', 'resumed']) {
94
+ out[vendor][kind] = { input: median(v[kind].input), output: median(v[kind].output), n: v[kind].input.length, cost: v[kind].cost.length ? median100(v[kind].cost) : null, nCost: v[kind].cost.length };
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function baseFor(vendor, kind, baselines) {
101
+ const b = baselines?.[vendor]?.[kind];
102
+ if (b && b.n > 0 && b.input > 0) return b;
103
+ return (FALLBACK[vendor] || GENERIC)[kind];
104
+ }
105
+
106
+ // Projection for one debate: every seat answers round 0 fresh and every critique
107
+ // round fresh again (memory 'stateless', the default) or resumed ('resume'); the chairman makes
108
+ // one long fresh call. Effort scales output tokens only.
109
+ export function project({ seats, chairman, critiqueRounds = 3, baselines = {}, memory = 'stateless', research = null, sources = null }) {
110
+ const bySeat = {}, byVendor = {};
111
+ const extra = research ? researchInputTokens(sources) : 0;
112
+ const add = (vendor, id, calls, model) => {
113
+ const sum = calls.reduce((a, c) => ({ input: a.input + c.input, output: a.output + c.output }), { input: 0, output: 0 });
114
+ const weight = modelWeight(model);
115
+ const total = Math.round(sum.input + sum.output);
116
+ // `weighted`: pool units, tokens times the model's weight; the pool bar and window share use it.
117
+ const cost = calls.every((c) => typeof c.cost === 'number') ? Math.round(calls.reduce((a, c) => a + c.cost, 0) * 100) / 100 : null;
118
+ const row = { vendor, id, model: model || null, weight, calls: calls.length, input: Math.round(sum.input), output: Math.round(sum.output), total, weighted: Math.round(total * weight), cost };
119
+ bySeat[id] = row;
120
+ const v = (byVendor[vendor] ??= { vendor, input: 0, output: 0, total: 0, weighted: 0, cost: null, seats: [] });
121
+ v.input += row.input; v.output += row.output; v.total += row.total; v.weighted += row.weighted; v.seats.push(id);
122
+ if (row.cost != null) v.cost = Math.round(((v.cost || 0) + row.cost) * 100) / 100;
123
+ };
124
+ // Dollar cost per call: the model's own median when measured, else the vendor's, scaled by the
125
+ // effort multiplier on the output share only (prompt tokens are the bulk of a call's cost).
126
+ const costFor = (vendor, model, kind) => {
127
+ const own = baselines?.[`${vendor}:${model || 'default'}`]?.[kind];
128
+ const b = (own?.nCost > 0 ? own : baselines?.[vendor]?.[kind]);
129
+ return b && b.nCost > 0 ? b.cost : null;
130
+ };
131
+ for (const s of seats || []) {
132
+ const m = EFFORT_MULTIPLIER[s.effort] ?? 1;
133
+ const fresh = baseFor(s.vendor, 'fresh', baselines), resumed = baseFor(s.vendor, 'resumed', baselines);
134
+ const cf = costFor(s.vendor, s.model, 'fresh'), cr = costFor(s.vendor, s.model, 'resumed');
135
+ const calls = [{ input: fresh.input + extra, output: fresh.output * m, cost: cf == null ? undefined : cf * (0.8 + 0.2 * m) + evidenceCost(cf, fresh, extra) }];
136
+ const later = memory === 'resume' ? { b: resumed, c: cr } : { b: fresh, c: cf };
137
+ for (let r = 0; r < critiqueRounds; r++) calls.push({ input: later.b.input + extra, output: later.b.output * m, cost: later.c == null ? undefined : later.c * (0.8 + 0.2 * m) + evidenceCost(later.c, later.b, extra) });
138
+ add(s.vendor, s.id, calls, s.model);
139
+ }
140
+ if (chairman?.vendor) {
141
+ const m = EFFORT_MULTIPLIER[chairman.effort] ?? 1;
142
+ const fresh = baseFor(chairman.vendor, 'fresh', baselines);
143
+ const cc = costFor(chairman.vendor, chairman.model, 'fresh');
144
+ add(chairman.vendor, 'chairman', [{ input: fresh.input * CHAIRMAN_INPUT_FACTOR + extra, output: fresh.output * 1.5 * m, cost: cc == null ? undefined : cc * CHAIRMAN_INPUT_FACTOR + evidenceCost(cc, fresh, extra) }], chairman.model);
145
+ }
146
+ if (research) {
147
+ const selected = typeof research === 'object' ? research : {}; // the searcher's own vendor, model and effort
148
+ const vendor = selected.vendor || 'codex';
149
+ const fresh = baseFor(vendor, 'fresh', baselines), m = EFFORT_MULTIPLIER[selected.effort] ?? 1;
150
+ add(vendor, 'research', [{ input: fresh.input * CHAIRMAN_INPUT_FACTOR, output: fresh.output * 1.5 * m }], selected.model);
151
+ }
152
+ const total = Object.values(byVendor).reduce((a, v) => a + v.total, 0);
153
+ return { bySeat, byVendor, total, critiqueRounds };
154
+ }
155
+
156
+ // Two quota snapshots of one vendor -> per-window change. A window whose reset time moved has
157
+ // rolled over in between, so its delta is meaningless and marked invalid.
158
+ // Two readings of one window can give its reset a minute apart: Claude's /usage prints the time to
159
+ // the minute and rounds it differently from one read to the next ("4:59am", then "5am"), and its
160
+ // reply gives the exact second. A rollover moves the reset by hours, so a few minutes is the same
161
+ // window.
162
+ const SAME_RESET_S = 300;
163
+ export function windowDeltas(prev, next) {
164
+ if (!prev?.windows || !next?.windows) return [];
165
+ return next.windows.map((w) => {
166
+ const p = prev.windows.find((x) => x.label === w.label);
167
+ if (!p) return { label: w.label, delta: 0, valid: false };
168
+ const a = p.resetsAt ?? null, b = w.resetsAt ?? null;
169
+ const sameWindow = a == null || b == null ? a === b : Math.abs(a - b) <= SAME_RESET_S;
170
+ const delta = w.used - p.used;
171
+ return { label: w.label, delta, valid: sameWindow && delta >= 0 };
172
+ });
173
+ }
174
+
175
+ // What one round of one pool can measure: its window deltas and the calibration keys they feed,
176
+ // the pool's and, when the round ran one model, `pool:model`. models: the Set of models it ran.
177
+ // A quota in groups (agy) moves only the windows of the group a model draws on, and the other
178
+ // groups standing still says nothing about these tokens. So only a one-model round is measured
179
+ // there, only on that model's group, and only under the model's key: a pool-wide rate would be
180
+ // projected onto every group.
181
+ export function roundDeltas(prev, next, pool, models) {
182
+ const deltas = windowDeltas(prev, next);
183
+ const model = models?.size === 1 ? [...models][0] : null;
184
+ if (!next?.groups?.length) return { deltas, keys: model ? [pool, `${pool}:${model}`] : [pool] };
185
+ const group = model && quotaGroup(next.groups, model);
186
+ if (!group) return { deltas: [], keys: [] };
187
+ const own = new Set(next.windows.filter((w) => w.group === group).map((w) => w.label));
188
+ return { deltas: deltas.filter((d) => own.has(d.label)), keys: [`${pool}:${model}`] };
189
+ }
190
+
191
+ // Vendors report window usage in whole percentage points and, at least for Claude, with a lag:
192
+ // a round rarely moves a window, and a move often lands a debate later. So the calibration is
193
+ // long-run: every valid round adds its tokens and its movement to one running total per key and
194
+ // window, and the rate is total movement over total tokens. Until the total has moved at all, the
195
+ // figure is a ceiling (less than one point per every token seen) that tightens with each token.
196
+ // A rolled-over window (reset time moved) contributes nothing for that round; the totals stand.
197
+ export const WINDOW_RESOLUTION = 0.01;
198
+
199
+ // calib: { [key]: { [label]: entry } }, key = vendor or vendor:model, entry =
200
+ // { moved, tokens, rounds, samples, pctPerToken, pending, ceilPerToken, pctPerRound }
201
+ // moved total window movement seen (fraction of the window)
202
+ // tokens total tokens behind that movement
203
+ // samples rounds in which a whole-point move landed
204
+ // pctPerToken moved / tokens once moved > 0, else 0
205
+ // pending alias of tokens while unmoved (what the UI reports the ceiling over)
206
+ // ceilPerToken resolution / tokens while unmoved
207
+ // pctPerRound mean movement per round (the pool row's coarse figure)
208
+ export function updateCalibration(calib, key, deltas, tokens, { resolution = WINDOW_RESOLUTION } = {}) {
209
+ const out = { ...calib, [key]: { ...(calib?.[key] || {}) } };
210
+ for (const d of deltas) {
211
+ if (!(tokens > 0) || !d.valid) continue;
212
+ const prev = out[key][d.label] || { moved: 0, tokens: 0, rounds: 0, samples: 0, pctPerRound: 0 };
213
+ const moved = prev.moved + d.delta, total = prev.tokens + tokens, rounds = prev.rounds + 1;
214
+ const samples = prev.samples + (d.delta >= resolution ? 1 : 0);
215
+ out[key][d.label] = {
216
+ moved, tokens: total, rounds, samples,
217
+ pctPerToken: moved > 0 ? moved / total : 0,
218
+ pending: moved > 0 ? 0 : total,
219
+ ceilPerToken: moved > 0 ? null : resolution / total,
220
+ pctPerRound: prev.pctPerRound + (d.delta - prev.pctPerRound) / rounds,
221
+ };
222
+ }
223
+ return out;
224
+ }
225
+
226
+ // What one entry says a token costs: a measurement, or a ceiling, or nothing yet.
227
+ function rate(entry) {
228
+ if (!entry) return null;
229
+ if (entry.moved > 0 || entry.samples > 0) return { perToken: entry.pctPerToken, upperBound: false, samples: entry.samples, tokens: entry.tokens };
230
+ if (entry.ceilPerToken) return { perToken: entry.ceilPerToken, upperBound: true, samples: 0, pending: entry.pending };
231
+ return null;
232
+ }
233
+
234
+ // Projected share of each window for one vendor's seats. calib is the whole calibration map,
235
+ // keyed `vendor` and `vendor:model`. A seat uses its model's own measurement when one exists,
236
+ // otherwise the vendor's measurement scaled by the model weight. null when nothing is measured.
237
+ export function projectedWindows(calib, vendor, seatRows) {
238
+ const base = calib?.[vendor];
239
+ const labels = new Set();
240
+ for (const key of Object.keys(calib || {})) if (key === vendor || key.startsWith(`${vendor}:`)) for (const l of Object.keys(calib[key])) labels.add(l);
241
+ if (!labels.size) return null;
242
+ const out = [];
243
+ for (const label of labels) {
244
+ let pct = 0, samples = 0, any = false, upperBound = false, pending = 0;
245
+ for (const row of seatRows || []) {
246
+ // The model's own measurement wins. Otherwise the vendor's rate times the model weight,
247
+ // capped by the model's own ceiling when that ceiling is the lower figure.
248
+ const own = rate(calib?.[`${vendor}:${row.model || 'default'}`]?.[label]);
249
+ const b = rate(base?.[label]);
250
+ let pick = null;
251
+ if (own && !own.upperBound) pick = { ...own, weight: 1 };
252
+ else if (b && !b.upperBound) {
253
+ const weighted = b.perToken * (row.weight ?? modelWeight(row.model));
254
+ pick = own && own.perToken < weighted ? { ...own, weight: 1 } : { ...b, weight: row.weight ?? modelWeight(row.model) };
255
+ } else if (own || b) {
256
+ const o = own ? own.perToken : Infinity, v = b ? b.perToken * (row.weight ?? modelWeight(row.model)) : Infinity;
257
+ pick = o <= v ? { ...own, weight: 1 } : { ...b, weight: row.weight ?? modelWeight(row.model) };
258
+ }
259
+ if (!pick) continue;
260
+ pct += pick.perToken * row.total * pick.weight; samples += pick.samples; any = true;
261
+ upperBound ||= pick.upperBound; pending = Math.max(pending, pick.pending || 0);
262
+ }
263
+ // upperBound: the window has not moved a whole point over `pending` tokens, so the figure is
264
+ // "less than this", tightening as tokens accumulate; otherwise it is a measurement.
265
+ if (any) out.push({ label, pct, samples, upperBound, pending });
266
+ }
267
+ return out.length ? out : null;
268
+ }
269
+
270
+ // What one round is likely to draw per vendor, before it runs: each seat's fresh or resumed
271
+ // baseline (fresh at round 0 and in stateless memory), effort on the output share. Used by the
272
+ // engine's budget gate. Returns { [vendor]: { tokens, cost|null, seats } }.
273
+ export function projectRound({ seats, round, baselines = {}, memory = 'stateless', researchTokens = 0 }) {
274
+ const out = {};
275
+ for (const s of seats || []) {
276
+ const kind = round === 0 || memory !== 'resume' ? 'fresh' : 'resumed';
277
+ const b = baseFor(s.vendor, kind, baselines);
278
+ const m = EFFORT_MULTIPLIER[s.effort] ?? 1;
279
+ const own = baselines?.[`${s.vendor}:${s.model || 'default'}`]?.[kind];
280
+ const cb = own?.nCost > 0 ? own : baselines?.[s.vendor]?.[kind];
281
+ const cost = cb && cb.nCost > 0 ? cb.cost * (0.8 + 0.2 * m) + evidenceCost(cb.cost, b, researchTokens) : null;
282
+ const v = (out[s.vendor] ??= { tokens: 0, cost: null, seats: 0 });
283
+ v.tokens += Math.round(b.input + researchTokens + b.output * m);
284
+ v.seats++;
285
+ if (cost != null) v.cost = Math.round(((v.cost || 0) + cost) * 100) / 100;
286
+ }
287
+ return out;
288
+ }
289
+
290
+ // The chairman's one long fresh call, same units.
291
+ export function projectChairman({ chairman, baselines = {}, researchTokens = 0 }) {
292
+ if (!chairman?.vendor) return {};
293
+ const b = baseFor(chairman.vendor, 'fresh', baselines);
294
+ const m = EFFORT_MULTIPLIER[chairman.effort] ?? 1;
295
+ const own = baselines?.[`${chairman.vendor}:${chairman.model || 'default'}`]?.fresh;
296
+ const cb = own?.nCost > 0 ? own : baselines?.[chairman.vendor]?.fresh;
297
+ const cost = cb && cb.nCost > 0 ? Math.round((cb.cost * CHAIRMAN_INPUT_FACTOR + evidenceCost(cb.cost, b, researchTokens)) * 100) / 100 : null;
298
+ return { [chairman.vendor]: { tokens: Math.round(b.input * CHAIRMAN_INPUT_FACTOR + researchTokens + b.output * 1.5 * m), cost, seats: 1 } };
299
+ }
300
+
301
+ // Would running `next` ({vendor: {tokens, cost}}) on top of `spent` cross `budget`
302
+ // ({vendor: {tokens?, usd?}})? Returns the first crossing, or null.
303
+ export function budgetCheck(budget, spent, next) {
304
+ for (const [vendor, cap] of Object.entries(budget || {})) {
305
+ if (!cap) continue;
306
+ // No new call to this vendor adds zero cost. An actual call whose projection is unknown
307
+ // still carries cost: null and must be refused under a dollar cap.
308
+ const s = spent?.[vendor] || { tokens: 0, cost: 0 }, n = next?.[vendor] || { tokens: 0, cost: 0 };
309
+ if (cap.tokens > 0 && s.tokensUnknown && next?.[vendor]) return { vendor, kind: 'tokens-unknown', spent: s.tokens, projected: n.tokens, cap: cap.tokens };
310
+ if (cap.tokens > 0 && s.tokens + n.tokens > cap.tokens) return { vendor, kind: 'tokens', spent: s.tokens, projected: n.tokens, cap: cap.tokens };
311
+ // A dollar cap cannot be checked against a cost nobody reported: that is a crossing of its
312
+ // own kind, not a zero.
313
+ if (cap.usd > 0 && (s.costUnknown || n.cost == null)) return { vendor, kind: 'usd-unknown', spent: Math.round((s.cost || 0) * 100) / 100, projected: n.cost ?? null, cap: cap.usd };
314
+ if (cap.usd > 0 && (s.cost || 0) + (n.cost || 0) > cap.usd) return { vendor, kind: 'usd', spent: Math.round((s.cost || 0) * 100) / 100, projected: n.cost, cap: cap.usd };
315
+ }
316
+ return null;
317
+ }
318
+
319
+ // Calibration on disk divides a window's move by the tokens a round spent. Before usage version 3
320
+ // a 'subset' vendor's tokens counted its cached input twice (Codex), so its rates are too low:
321
+ // drop those entries (the vendor and its vendor:model keys) once, and mark the file version 3.
322
+ // Entries for other vendors measured the same tokens then as now and are kept.
323
+ export const CALIBRATION_VERSION = 3;
324
+ export function migrateCalibration(file, { subsetVendors = [] } = {}) {
325
+ if (!file || typeof file !== 'object') return { calibration: {}, dropped: [] };
326
+ const { _version, ...entries } = file;
327
+ if (_version >= CALIBRATION_VERSION) return { calibration: entries, dropped: [] };
328
+ const dropped = Object.keys(entries).filter((k) => subsetVendors.includes(k.split('@')[0].split(':')[0]));
329
+ for (const k of dropped) delete entries[k];
330
+ return { calibration: entries, dropped };
331
+ }
package/src/export.js ADDED
@@ -0,0 +1,307 @@
1
+ // Decision records: one plain-markdown file per debate, written into a codebase so an agent
2
+ // that can read the repo (sandboxed or not) can read what was decided and why, plus an index
3
+ // README.md listing every record. Pure functions over transcript rows, plus two file writers.
4
+ //
5
+ // decisionRecord(rows, slug) -> { markdown, meta } (pure)
6
+ // updateIndex(existing, meta) -> new index text, or null when the file is not ours (pure)
7
+ // validateTarget(target) -> absolute path of an existing directory, or throws
8
+ // exportDebate(rows, slug, target) -> { path, indexPath, meta } (writes both files)
9
+ //
10
+ // The exporter writes only into files it can prove it wrote: a record whose front matter names
11
+ // this slug under `roundtable:`, and an index region between two marker comments. Everything
12
+ // outside the region is kept byte for byte; any other file in the way is refused, never
13
+ // replaced.
14
+ import { existsSync, statSync, lstatSync, realpathSync, readFileSync, writeFileSync, openSync, closeSync, fchmodSync, renameSync, unlinkSync } from 'node:fs';
15
+ import { randomUUID } from 'node:crypto';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { rebuildDebate, normaliseRows } from './store.js';
19
+ import { rollup } from './engine.js';
20
+ import { optionIds, optionId, optionLabel, recommendationPick, verdictSections, VERDICT_HEADERS } from './contract.js';
21
+ import { normLabel } from './validate.js';
22
+ import { noteRecord, noteAuthor } from './notes.js';
23
+
24
+ // The chairman's sections, same tolerance as the board (markdown emphasis around the header).
25
+ export function parseVerdict(text) {
26
+ const sections = verdictSections(text);
27
+ const out = {};
28
+ for (const h of VERDICT_HEADERS) out[h] = (sections[h] || []).map((l) => l.trim()).filter(Boolean).join('\n');
29
+ out.parsed = Object.keys(sections).length > 0;
30
+ return out;
31
+ }
32
+
33
+ // A 0.2.0 verdict has no grammar line. Its first line counts only when it begins with one exact
34
+ // option label followed by punctuation or nothing ("Ship now, on balance."), longest label first,
35
+ // so "Ship later, ..." never reads as "Ship" and "Do not use Postgres" never reads as Postgres.
36
+ function leadingOption(line, options) {
37
+ const s = normLabel(line || '');
38
+ for (const o of [...optionIds(options)].sort((a, b) => b.label.length - a.label.length)) {
39
+ const l = normLabel(o.label);
40
+ if (s === l || (s.startsWith(l) && /^[\s]*[,.;:!?(\u2014\u2013-]/.test(s.slice(l.length)))) return o.id;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ // Which option the debate chose, as its label:
46
+ // - the option on the recommendation's first line;
47
+ // - 'no pick' when the chairman wrote none: an abstention is never filled in from the seats;
48
+ // - for a legacy verdict (no grammar) or a missing one, the option every seat ended on, and only
49
+ // when every seat has an eligible final stance;
50
+ // - otherwise 'no consensus'. Substring presence never counts.
51
+ // finalStances: one per seat, an option id or label, or null for a seat with no eligible stance.
52
+ export function chosenOption(options, recommendation, finalStances, { legacy = true } = {}) {
53
+ const first = String(recommendation || '').split(/\r?\n/).find((l) => l.trim()) || '';
54
+ const pick = recommendationPick(first, options) ?? (legacy ? leadingOption(first, options) : null);
55
+ if (pick === 'none') return 'no pick';
56
+ if (pick) return optionLabel(pick, options);
57
+ if (!legacy) return 'no consensus';
58
+ const ids = finalStances.map((s) => (s == null ? null : optionId(s, options)));
59
+ if (ids.length && ids.every(Boolean) && new Set(ids).size === 1) return optionLabel(ids[0], options);
60
+ return 'no consensus';
61
+ }
62
+
63
+ const STOPPED = new Set(['human', 'wall-clock', 'budget', 'all-failed', 'seats-down']);
64
+ const yaml = (v) => (typeof v === 'string' && /[:#"'\n]|^\s|\s$/.test(v) ? JSON.stringify(v) : String(v));
65
+ const k = (n) => (n || 0).toLocaleString('en-US');
66
+
67
+ export function decisionRecord(rows, slug, { warnings = [], metadataOnly = false } = {}) {
68
+ rows = normaliseRows(rows);
69
+ const rb = rebuildDebate(rows);
70
+ if (!rb) throw new Error('No field-notes row: nothing to export.');
71
+ const fn = rb.fieldNotes;
72
+ const options = fn.options || [];
73
+ const verdictRow = rb.verdict;
74
+ const v = parseVerdict(verdictRow?.skipped ? '' : verdictRow?.text);
75
+ const date = (rows.find((r) => r.t)?.t || new Date().toISOString()).slice(0, 10);
76
+ const seats = rb.seats;
77
+ const finalRound = rb.rounds.length - 1;
78
+ // A seat's final stance: its last reply's, where only a PASS carries an earlier stance forward.
79
+ // A failed or off-contract last reply leaves the seat with no eligible stance (null).
80
+ const finalStance = (seat) => {
81
+ for (let i = finalRound; i >= 0; i--) {
82
+ const r = rb.rounds[i]?.find((x) => (x.seat.id ?? `${x.seat.vendor}:${x.seat.lens}`) === (seat.id ?? `${seat.vendor}:${seat.lens}`));
83
+ if (!r) continue;
84
+ if (r.failed || !r.valid) return null;
85
+ if (r.stanceKey !== 'PASS') return r.stanceKey;
86
+ }
87
+ return null;
88
+ };
89
+ const head = rows.find((r) => r.type === 'field-notes');
90
+ const finals = seats.map(finalStance);
91
+ // the tally, counted in code from the final stances (the chairman's own is kept for comparison)
92
+ const counts = {};
93
+ for (const f of finals) { const k = f ? optionLabel(f, options) : 'no eligible stance'; counts[k] = (counts[k] || 0) + 1; }
94
+ const tallyCount = Object.keys(counts).length ? `Final: ${Object.entries(counts).sort((x, y) => y[1] - x[1]).map(([k, n]) => `${k} ${n}`).join(', ')}.` : '';
95
+ const legacy = !(head?.schemaVersion >= 3);
96
+ // a failed verdict (the chairman never met the grammar) is diagnostic text: it never chooses
97
+ const failedVerdict = !!(verdictRow?.failed && !verdictRow.skipped);
98
+ const chosen = failedVerdict ? 'no verdict' : chosenOption(options, v.RECOMMENDATION, finals, { legacy });
99
+ // The AI recommends; only a human decides. `accepted` comes from an `accepted` row (POST
100
+ // .../accept) and may differ from the recommendation; both are written.
101
+ const acceptedRow = [...rows].reverse().find((r) => r.type === 'accepted');
102
+ const accepted = acceptedRow ? (acceptedRow.option === 'none' ? 'no option' : optionLabel(acceptedRow.option, options)) : null;
103
+ const recommended = chosen;
104
+ const status = accepted ? 'accepted' : failedVerdict ? 'no verdict' : !rb.finished || !verdictRow || verdictRow.skipped || STOPPED.has(rb.stopReason) ? 'stopped' : chosen === 'no consensus' || chosen === 'no pick' ? 'split' : 'recommended';
105
+ const usage = rollup(rows);
106
+ const recoveryMessages = [...new Set([...rb.recoveryWarnings, ...warnings].map((w) => w.message).filter(Boolean))];
107
+ const meta = { title: fn.decision, date, slug, status, chosen: accepted ?? chosen, recommended, accepted, stopReason: rb.stopReason, file: `${slug}.md` };
108
+ if (metadataOnly) return { meta, summary: { decision: fn.decision, finished: rb.finished, seats: seats.length, rounds: rb.rounds.length, usage: usage.total, warning: recoveryMessages.at(-1) || null } };
109
+
110
+ // where the seat ran is part of the record: a CLI on a subscription, or an API endpoint by URL
111
+ const where = (s) => (s.endpoint ? `API ${s.endpoint}` : 'CLI');
112
+ const seatLine = (s) => [s.label || s.vendor, where(s), s.lens, s.mood?.preset === 'custom' ? `custom: ${s.mood.text}` : (s.mood?.preset || 'grill'), s.model || 'default model', s.effort || 'default effort'].join(', ');
113
+ const front = [
114
+ '---',
115
+ `title: ${yaml(fn.decision)}`,
116
+ `date: ${date}`,
117
+ `slug: ${slug}`,
118
+ `status: ${status}`,
119
+ `stop_reason: ${rb.stopReason || 'none'}`,
120
+ 'options:',
121
+ ...options.map((o) => ` - ${yaml(o)}`),
122
+ `chosen: ${yaml(accepted ?? chosen)}`,
123
+ `recommended: ${yaml(recommended)}`,
124
+ ...(acceptedRow ? [`accepted: ${yaml(accepted)}`, `accepted_on: ${String(acceptedRow.t || '').slice(0, 10) || 'unknown'}`] : []),
125
+ 'seats:',
126
+ ...seats.map((s) => ` - ${yaml(seatLine(s))}`),
127
+ `chairman: ${rb.chairman?.vendor || 'none'}${rb.chairman?.endpoint ? ` at ${rb.chairman.endpoint}` : ''}${rb.chairman?.model ? ` (${rb.chairman.model})` : ''}`,
128
+ `rounds: ${rb.rounds.length}`,
129
+ `roundtable: ${slug}`,
130
+ '---',
131
+ ];
132
+ const section = (title, body) => [`## ${title}`, '', body && body.trim() ? body.trim() : '_not given_', ''];
133
+ const list = (text) => text; // the chairman already writes bullets
134
+ const notesBody = [
135
+ fn.constraints ? `- **Constraints:** ${fn.constraints}` : null,
136
+ fn.known ? `- **Evidence, known:** ${fn.known}` : null,
137
+ fn.assumed ? `- **Evidence, assumed:** ${fn.assumed}` : null,
138
+ fn.reversibility ? `- **Reversibility:** ${fn.reversibility}` : null,
139
+ fn.deadline ? `- **Deadline:** ${fn.deadline}` : null,
140
+ ].filter(Boolean).join('\n');
141
+ const tallyRows = seats.map((s, i) => {
142
+ const per = rb.rounds.map((round) => {
143
+ const r = round.find((x) => (x.seat.id ?? `${x.seat.vendor}:${x.seat.lens}`) === (s.id ?? `${s.vendor}:${s.lens}`));
144
+ return r ? (r.failed ? 'no reply' : optionLabel(r.stanceKey, options)) : '';
145
+ });
146
+ return `| ${s.label || s.vendor} · ${s.lens} | ${per.join(' | ')} | ${finals[i] ? optionLabel(finals[i], options) : 'none'} |`;
147
+ });
148
+ const tally = rb.rounds.length
149
+ ? [`| seat | ${rb.rounds.map((_, i) => `round ${i}`).join(' | ')} | final |`, `|---|${rb.rounds.map(() => '---').join('|')}|---|`, ...tallyRows].join('\n')
150
+ : '';
151
+ const formatNotes = (notes) => notes.map((note) => `**${noteAuthor(note)}:** ${noteRecord(note).text}`).join('\n\n');
152
+ const humanNotes = [rb.initialNotes?.length ? `### Before round 0\n\n${formatNotes(rb.initialNotes)}` : null,
153
+ ...rb.humanNotes.map((ns, i) => (ns?.length ? `### After round ${i}\n\n${formatNotes(ns)}` : null))].filter(Boolean).join('\n\n');
154
+ const costCell = (u) => (u.cost == null ? 'not reported' : `$${u.cost.toFixed(2)}${u.costCoverage && u.costCoverage.reported < u.costCoverage.of ? ` (partial: ${u.costCoverage.reported} of ${u.costCoverage.of} calls report a cost)` : ''}`);
155
+ const tokens = (n, u) => u.tokenCoverage?.reported === 0 ? 'unknown'
156
+ : `${k(n)}${u.tokenCoverage && u.tokenCoverage.reported < u.tokenCoverage.of ? ' (partial)' : ''}`;
157
+ const usageRows = seats.map((s) => {
158
+ const u = usage.bySeat[s.id ?? `${s.vendor}:${s.lens}`]?.usage;
159
+ return u ? `| ${s.label || s.vendor} · ${s.lens} | ${tokens(u.input, u)} | ${tokens(u.cacheRead + u.cacheWrite, u)} | ${tokens(u.output, u)} | ${costCell(u)} |` : null;
160
+ }).filter(Boolean);
161
+ const ch = usage.bySeat.chairman?.usage;
162
+ const researchUsage = usage.bySeat.research?.usage;
163
+ if (researchUsage) usageRows.push(`| searcher (${usage.bySeat.research.seat?.vendor || rb.options?.research?.vendor || 'codex'}) | ${tokens(researchUsage.input, researchUsage)} | ${tokens(researchUsage.cacheRead + researchUsage.cacheWrite, researchUsage)} | ${tokens(researchUsage.output, researchUsage)} | ${costCell(researchUsage)} |`);
164
+ if (ch) usageRows.push(`| chairman (${rb.chairman?.vendor || '?'}) | ${tokens(ch.input, ch)} | ${tokens(ch.cacheRead + ch.cacheWrite, ch)} | ${tokens(ch.output, ch)} | ${costCell(ch)} |`);
165
+ const t = usage.total;
166
+ const usageTable = usageRows.length
167
+ ? ['| seat | input | cache | output | cost |', '|---|---|---|---|---|', ...usageRows, `| **debate** | ${tokens(t.input, t)} | ${tokens(t.cacheRead + t.cacheWrite, t)} | ${tokens(t.output, t)} | ${costCell(t)} |`].join('\n')
168
+ : '';
169
+ // a grammar line names an id: show its label in its place
170
+ const recText = v.parsed ? v.RECOMMENDATION.replace(/^\s*(opt-\d+)\b\.?/, (m, id) => `**${optionLabel(id, options)}** (${id})`) : '';
171
+ const recommendation = verdictRow?.skipped ? verdictRow.text : (v.parsed ? recText : (verdictRow?.text || ''));
172
+
173
+ const md = [
174
+ ...front,
175
+ '',
176
+ `# ${fn.decision}`,
177
+ '',
178
+ acceptedRow
179
+ ? `Accepted by the human on ${String(acceptedRow.t || date).slice(0, 10)}: **${accepted}**. Recommended by a roundtable of ${seats.length} seats on ${date}: **${recommended}** (stopped because: ${rb.stopReason || 'not stopped'}).`
180
+ : `Recommended by a roundtable of ${seats.length} seats on ${date}: **${recommended}** (${status}, stopped because: ${rb.stopReason || 'not stopped'}). Not yet accepted by a human.`,
181
+ ...(acceptedRow?.note ? ['', `> ${String(acceptedRow.note).replace(/\r?\n/g, ' ')}`] : []),
182
+ '',
183
+ ...(recoveryMessages.length ? section('Record recovery', recoveryMessages.join('\n\n')) : []),
184
+ ...section('Question', `${fn.decision}\n\nOptions:\n${options.map((o) => `- ${o}`).join('\n')}`),
185
+ ...section('Field notes', notesBody),
186
+ ...(rb.research ? section('Shared web evidence', rb.research.status === 'done'
187
+ ? ['Provider-supplied citations, excerpts and publication dates; not independently verified. Every seat received this saved bundle.',
188
+ `Gathered: ${rb.research.gatheredAt}. Reported search operations: ${rb.research.searches}.`,
189
+ ...rb.research.sources.map((s) => `### ${s.id}: ${s.title.replace(/[\r\n]/g, ' ')}\n\n<${s.url}>\n\nPublished: ${s.publishedAt || 'not reported'}\n\n> ${s.excerpt.replace(/\r?\n/g, '\n> ')}`)].join('\n\n')
190
+ : rb.research.error || 'Research was interrupted; no source bundle was saved.') : []),
191
+ ...section('Tally', [tally, tallyCount].filter(Boolean).join('\n\n')),
192
+ ...section('Recommendation', recommendation),
193
+ ...(v.TALLY ? section("Chairman's tally", v.TALLY) : []),
194
+ ...section('Dissent', list(v.DISSENT)),
195
+ ...section('Kill criteria', list(v.KILL_CRITERIA)),
196
+ ...section('Open questions', list(v.OPEN_QUESTIONS)),
197
+ ...section('Discussion notes', humanNotes),
198
+ ...section('Usage', [usageTable, usage.unknownAttempts ? `${usage.unknownAttempts} call(s) did not report usable token counts. Totals include reported figures only; unknown usage is not zero.` : ''].filter(Boolean).join('\n\n')),
199
+ `_Exported by roundtable from debates/${slug}/transcript.jsonl._`,
200
+ '',
201
+ ].join('\n');
202
+ return { markdown: md, meta };
203
+ }
204
+
205
+ const INDEX_INTRO = ['# Decision records', '', 'One file per roundtable debate, newest first. Written by `npm run export`; re-exporting a debate replaces its row.', ''];
206
+ const TABLE_HEAD = ['| date | title | chosen | status | record |', '|---|---|---|---|---|'];
207
+ const REGION_START = '<!-- roundtable:index -->';
208
+ const REGION_END = '<!-- roundtable:index:end -->';
209
+ const cell = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
210
+ const isRow = (l) => /^\|\s*\d{4}-\d{2}-\d{2}\s*\|/.test(l);
211
+
212
+ // The index: create it, or replace this slug's row inside our marked region. A legacy index
213
+ // (written before the markers, recognised by its whole header, not by a date column) is
214
+ // converted, keeping what was written around its table. Returns null for any other file.
215
+ export function updateIndex(existing, meta) {
216
+ const row = `| ${meta.date} | ${cell(meta.title)} | ${cell(meta.chosen)} | ${meta.status} | [${meta.file}](./${meta.file}) |`;
217
+ const region = (oldRows) => {
218
+ const rows = oldRows.filter(isRow).filter((l) => !l.includes(`](./${meta.file})`));
219
+ rows.push(row);
220
+ rows.sort((a, b) => (b.slice(2, 12).localeCompare(a.slice(2, 12))) || a.localeCompare(b));
221
+ return [REGION_START, ...TABLE_HEAD, ...rows, REGION_END];
222
+ };
223
+ if (existing == null) return [...INDEX_INTRO, ...region([]), ''].join('\n');
224
+ const eol = existing.includes('\r\n') ? '\r\n' : '\n';
225
+ const lines = existing.split(/\r?\n/);
226
+ const start = lines.indexOf(REGION_START), end = lines.indexOf(REGION_END);
227
+ if (start >= 0 && end > start) return [...lines.slice(0, start), ...region(lines.slice(start + 1, end)), ...lines.slice(end + 1)].join(eol);
228
+ // legacy: our title, our sentence, our exact table header
229
+ const header = lines.indexOf(TABLE_HEAD[0]);
230
+ const legacy = lines[0] === INDEX_INTRO[0] && lines.some((l) => l.startsWith('One file per roundtable debate') && l.includes('Written by `npm run export`')) && header > 0 && lines[header + 1] === TABLE_HEAD[1];
231
+ if (!legacy) return null;
232
+ let after = header + 2;
233
+ while (after < lines.length && isRow(lines[after])) after++;
234
+ return [...lines.slice(0, header), ...region(lines.slice(header + 2, after)), ...lines.slice(after)].join(eol);
235
+ }
236
+
237
+ // A record file is ours when its front matter names this slug under `roundtable:`; a legacy
238
+ // record (before that key) when it has our `slug:` line and our opening sentence.
239
+ export function ownsRecord(text, slug) {
240
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] || '';
241
+ if (new RegExp(`^roundtable: ${slug}$`, 'm').test(front)) return true;
242
+ return new RegExp(`^slug: ${slug}$`, 'm').test(front) && /\nDecided by a roundtable of \d+ seats on /.test(text); // the 0.2.0 sentence
243
+ }
244
+
245
+ // An existing directory, as an absolute path. Records are named from the slug, which is
246
+ // restricted to [a-z0-9-], so the written path can only be directly inside it.
247
+ // forbid: further directories the caller will not write into (the server's own debates dir).
248
+ export function validateTarget(target, { forbid = [] } = {}) {
249
+ if (!target || typeof target !== 'string') throw new Error('Export needs a target directory.');
250
+ let abs = path.resolve(target);
251
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) throw new Error(`Export target is not an existing directory: ${abs}`);
252
+ abs = realpathSync(abs);
253
+ const same = (dir) => path.relative(existsSync(dir) ? realpathSync(dir) : path.resolve(dir), abs) === '';
254
+ if (path.parse(abs).root === abs) throw new Error(`Refusing to export into a filesystem root: ${abs}`);
255
+ if (same(os.homedir())) throw new Error(`Refusing to export into the home directory: ${abs}. Pick a decisions folder inside a project.`);
256
+ for (const f of forbid) if (same(f)) throw new Error(`Refusing to export into ${abs}: roundtable keeps its own transcripts there.`);
257
+ return abs;
258
+ }
259
+
260
+ export function recordPath(target, slug, opts) {
261
+ if (!/^[a-z0-9-]+$/.test(slug)) throw new Error(`Bad slug: ${slug}`);
262
+ const abs = validateTarget(target, opts);
263
+ const file = path.resolve(abs, `${slug}.md`);
264
+ if (path.dirname(file) !== abs) throw new Error('Refusing to write outside the target directory.');
265
+ return file;
266
+ }
267
+
268
+ // Temp file in the same directory, then rename: a reader never sees half a file.
269
+ function writeAtomic(file, text, mode) {
270
+ const tmp = `${file}.${randomUUID()}.tmp`;
271
+ const fd = openSync(tmp, 'wx', mode ?? 0o666);
272
+ try {
273
+ try {
274
+ writeFileSync(fd, text);
275
+ // Creation respects umask; an existing record/index keeps its exact access bits.
276
+ if (mode != null && process.platform !== 'win32') fchmodSync(fd, mode);
277
+ } finally { closeSync(fd); }
278
+ renameSync(tmp, file);
279
+ } finally { if (existsSync(tmp)) unlinkSync(tmp); }
280
+ }
281
+
282
+ function regularOrAbsent(file) {
283
+ try {
284
+ const s = lstatSync(file);
285
+ if (!s.isFile() || s.isSymbolicLink()) throw new Error(`Refusing linked or non-file export destination: ${file}`);
286
+ return s.mode & 0o777;
287
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
288
+ }
289
+
290
+ // Every check runs before the first write, so a refusal leaves the directory untouched.
291
+ export function exportDebate(rows, slug, target, opts) {
292
+ const file = recordPath(target, slug, opts);
293
+ const mode = regularOrAbsent(file);
294
+ if (existsSync(file) && !ownsRecord(readFileSync(file, 'utf8'), slug)) throw new Error(`Refusing to replace ${file}: it was not written by roundtable for this debate.`);
295
+ const { markdown, meta } = decisionRecord(rows, slug, opts);
296
+ let indexPath = null, indexText = null, indexMode;
297
+ for (const name of ['README.md', 'DECISIONS.md']) {
298
+ const p = path.join(path.dirname(file), name);
299
+ const candidateMode = regularOrAbsent(p);
300
+ const next = updateIndex(existsSync(p) ? readFileSync(p, 'utf8') : null, meta);
301
+ if (next != null) { indexPath = p; indexText = next; indexMode = candidateMode; break; }
302
+ }
303
+ if (!indexPath) throw new Error(`Refusing to export into ${path.dirname(file)}: README.md and DECISIONS.md both exist and neither is a roundtable index.`);
304
+ writeAtomic(file, markdown, mode);
305
+ writeAtomic(indexPath, indexText, indexMode);
306
+ return { path: file, indexPath, meta };
307
+ }