the-high-council 0.6.0

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 (78) hide show
  1. package/.env.example +19 -0
  2. package/CONTRIBUTING.md +41 -0
  3. package/LICENSE +21 -0
  4. package/PERSONAS.md +51 -0
  5. package/README.md +499 -0
  6. package/TROUBLESHOOTING.md +45 -0
  7. package/chains/cheap.json +35 -0
  8. package/chains/gp-judge-v1.json +80 -0
  9. package/chains/idea-open-c2.json +106 -0
  10. package/chains/mock-budget.json +15 -0
  11. package/chains/mock-debate.json +59 -0
  12. package/chains/mock-external.json +59 -0
  13. package/chains/mock-open.json +60 -0
  14. package/chains/mock-proposals.json +57 -0
  15. package/chains/mock-questions-wait.json +63 -0
  16. package/chains/mock-questions.json +63 -0
  17. package/chains/mock-relay.json +42 -0
  18. package/chains/mock-unanimous.json +42 -0
  19. package/chains/mock.json +17 -0
  20. package/chains/plan-auto-mistral.json +101 -0
  21. package/chains/plan-auto.json +131 -0
  22. package/chains/plan-cheap.json +82 -0
  23. package/chains/plan-debate-c2.json +86 -0
  24. package/chains/plan-debate-open-c2.json +98 -0
  25. package/chains/plan-debate-roles-c1.json +149 -0
  26. package/chains/plan-debate.json +116 -0
  27. package/chains/plan-fast.json +66 -0
  28. package/chains/plan-proposals-best-of-5.json +98 -0
  29. package/chains/plan-proposals.json +96 -0
  30. package/chains/plan-relay.json +83 -0
  31. package/chains/plan-thorough.json +82 -0
  32. package/chains/plan-unanimous.json +82 -0
  33. package/chains/seven-cheap.json +67 -0
  34. package/chains/seven.json +60 -0
  35. package/chains/smo-v2-cheap.json +67 -0
  36. package/chains/smo.json +50 -0
  37. package/chains/verify.json +40 -0
  38. package/package.json +55 -0
  39. package/src/board-export.js +77 -0
  40. package/src/budget.js +126 -0
  41. package/src/buys.js +44 -0
  42. package/src/cache-integrity.js +38 -0
  43. package/src/chain-lint.js +107 -0
  44. package/src/chain.js +880 -0
  45. package/src/cli.js +1031 -0
  46. package/src/cost-forecast.js +70 -0
  47. package/src/cost.js +139 -0
  48. package/src/errors.js +82 -0
  49. package/src/http-server.js +349 -0
  50. package/src/integrity.js +48 -0
  51. package/src/key-redaction.js +97 -0
  52. package/src/mcp/server.js +319 -0
  53. package/src/name-lint.js +186 -0
  54. package/src/partial-deliverable.js +38 -0
  55. package/src/peer-claim.js +98 -0
  56. package/src/personas.js +54 -0
  57. package/src/preflight.js +84 -0
  58. package/src/pricing.json +120 -0
  59. package/src/providers.js +268 -0
  60. package/src/replay.js +40 -0
  61. package/src/resume-brief.js +114 -0
  62. package/src/role-diagnostics.js +181 -0
  63. package/src/roles.js +520 -0
  64. package/src/schema-version.js +39 -0
  65. package/src/scope-freeze.js +35 -0
  66. package/src/seat-role.js +96 -0
  67. package/src/shape-rounds.js +58 -0
  68. package/src/spend.js +193 -0
  69. package/src/stage-contract.js +223 -0
  70. package/src/stage-submission.js +53 -0
  71. package/src/tie-break.js +94 -0
  72. package/src/ui/app.js +139 -0
  73. package/src/ui/index.html +43 -0
  74. package/src/ui/parse.js +101 -0
  75. package/src/ui/server.js +117 -0
  76. package/src/ui/style.css +37 -0
  77. package/src/verdict-stats.js +267 -0
  78. package/src/withdrawal-ledger.js +47 -0
package/src/cli.js ADDED
@@ -0,0 +1,1031 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync, rmSync, readdirSync } from 'node:fs';
3
+ import { join, dirname, resolve, basename } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { runChain, checkSeats, setCache, setBudget, budgetState, ExternalPause, BudgetExceeded } from './chain.js';
6
+ import { summarise, formatUsd, priceOf, estimateChainRows } from './cost.js';
7
+ import { providerNames, envKeyName, keyFor } from './providers.js';
8
+ import { spendReport, costToday } from './spend.js';
9
+ import { verdictStats, independenceStatsCsv } from './verdict-stats.js';
10
+ import { withIntegrityFooter } from './integrity.js';
11
+ import { generateResumeBrief } from './resume-brief.js';
12
+ import { preflightCheck, checkArtifactReferences } from './preflight.js';
13
+ import { stageKindOf } from './stage-contract.js';
14
+ import { validateDeliverable } from './partial-deliverable.js';
15
+ import { fingerprintInputs, withStalenessCheck } from './cache-integrity.js';
16
+ import { taskHashOf, checkFrozenScope } from './scope-freeze.js';
17
+ import { withdrawalLedger } from './withdrawal-ledger.js';
18
+ import { schemaVersionWarning } from './schema-version.js';
19
+ import { forecastCost } from './cost-forecast.js';
20
+ import { renderBoardHtml } from './board-export.js';
21
+ import { buildTranscript, renderTranscriptText } from './replay.js';
22
+ import { lintChain } from './chain-lint.js';
23
+ import { computeRoleDiagnostics } from './role-diagnostics.js';
24
+ import { formatCouncilError, ERROR_CATALOG } from './errors.js';
25
+
26
+ // v5 §1 candidate 4: distinct exit codes for a degradable condition (a
27
+ // stranger can fix it and continue - a missing key, an unpriced model)
28
+ // vs. a fatal one (nothing more can happen this invocation - a chain
29
+ // file that can't even be parsed). 1-4 are already used by this file's
30
+ // own existing exit paths (usage errors, ExternalPause, BudgetExceeded).
31
+ const EXIT_DEGRADABLE = 5;
32
+ const EXIT_FATAL = 6;
33
+ import { scanArtifacts } from './key-redaction.js';
34
+
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+
37
+ // Two roots, and the distinction matters once this is installed from npm
38
+ // rather than cloned. `pkg` is where the shipped assets live (chains/,
39
+ // pricing.json) - inside node_modules for an npx user. `work` is the user's
40
+ // own directory, where their .env, their task files and their run output
41
+ // belong. Resolving a task path against pkg sends an npx user looking for
42
+ // their own file inside node_modules, which is exactly what it did.
43
+ // In a cloned checkout the two are the same directory, so nothing changes.
44
+ const pkg = resolve(here, '..');
45
+ const work = process.cwd();
46
+
47
+ // Minimal .env loader. No dependency for four lines of parsing. The user's
48
+ // own directory wins; the package copy is the fallback that makes a cloned
49
+ // checkout behave as before.
50
+ const envPath = [join(work, '.env'), join(pkg, '.env')].find(existsSync) || join(work, '.env');
51
+ if (existsSync(envPath)) {
52
+ for (const line of readFileSync(envPath, 'utf8').split('\n')) {
53
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
54
+ if (m && m[2] && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
55
+ }
56
+ }
57
+
58
+ // `npx <local-tarball-or-package-spec> council demo` is a real invocation, not
59
+ // a hypothetical one: once npx has resolved a package spec to this package's
60
+ // one bin, it does not also strip a literal repeat of the bin's own name from
61
+ // the arguments that follow, so that repeat lands as argv[0] here. The
62
+ // `doctor`/`demo` checks below key off argv[0], so a redundant leading
63
+ // `council` (or its `relay` alias) silently fell through to the usage banner
64
+ // instead of running - caught by the 2026-09-13 tarball verification pass.
65
+ // Drop one such leading token before anything else looks at argv[0].
66
+ const rawArgv = process.argv.slice(2);
67
+ const argv = (rawArgv[0] === 'council' || rawArgv[0] === 'relay') ? rawArgv.slice(1) : rawArgv;
68
+ function flag(name, fallback) {
69
+ const i = argv.indexOf(`--${name}`);
70
+ if (i === -1) return fallback;
71
+ const next = argv[i + 1];
72
+ return (next && !next.startsWith('--')) ? next : true;
73
+ }
74
+
75
+ // The shape of a run's report.json, in one place - bug-audit finding
76
+ // (2026-09-13, v5 Phase 2): `council init`'s canned demo run used to
77
+ // hand-roll a second, independently-maintained literal missing fields
78
+ // (criteria, proposals, ...) the real writer below includes, so
79
+ // `--from-run` against an init-produced run silently reran the criteria
80
+ // stage instead of reusing it - no crash, just a broken promise. Both
81
+ // writers now build from this one function.
82
+ function reportJsonShape({ runId, chain, task, result, fromRun = null, maxUsd = null, config = null }) {
83
+ // v6 §7: failure-mode diagnostics, computed from this run's own real
84
+ // debate output - never from the phase 4 measurement harness, which
85
+ // is a deterministic heuristic probe and cannot speak to real debate
86
+ // diversity (see docs/v6-decisions.md). Additive-only: a chain with
87
+ // no debate stage keeps debate as-is (null); one that did debate
88
+ // always gets a diagnostics object, with nulls/empty flags rather
89
+ // than an absent key when there's nothing to compute.
90
+ //
91
+ // v6 phase 7 bug-audit fix: role only ever takes effect on a
92
+ // seats.proposers entry - chain.js's debate-stage seatOf(lab) looks
93
+ // seats up exclusively from proposers, so a role set on any other
94
+ // seat kind (builder, judge, critics, ...) is already a silent no-op
95
+ // (see chain-lint.js's new warning for that). This used to pull role
96
+ // from every seat kind, which could misclassify a proposer's own
97
+ // debate posts as role-bearing via a shared provider-as-lab fallback
98
+ // string with an unrelated non-proposer seat that happened to carry
99
+ // a role. Restricted to proposers, the only seats debate ever touches.
100
+ const roleLabs = new Set(
101
+ (config?.seats?.proposers || [])
102
+ .filter(s => s?.role)
103
+ .map(s => s.lab || s.provider) // same lab || provider fallback chain.js's own labOf uses
104
+ );
105
+ const debate = result.debate
106
+ ? { ...result.debate, diagnostics: computeRoleDiagnostics(result.debate, roleLabs) }
107
+ : result.debate;
108
+
109
+ return {
110
+ runId,
111
+ chain,
112
+ task,
113
+ fromRun,
114
+ criteria: result.criteria,
115
+ questions: result.questions,
116
+ passed: result.passed,
117
+ lastCritique: result.lastCritique,
118
+ signoff: result.signoff,
119
+ proposals: result.proposals,
120
+ dropouts: result.dropouts,
121
+ debate,
122
+ scoreboard: result.scoreboard,
123
+ disputes: result.disputes,
124
+ orphanSections: result.orphanSections,
125
+ withdrawalCycles: result.withdrawalCycles,
126
+ totals: result.totals,
127
+ maxUsd,
128
+ stages: result.stages.map(({ text, ...rest }) => rest),
129
+ };
130
+ }
131
+
132
+ // A run folder's report.json can exist but still be unreadable - truncated
133
+ // by a killed run, or hand-edited - and every read-only reporting command
134
+ // (doctor --run, export-board, replay) must degrade to a clean error
135
+ // rather than an uncaught SyntaxError and a raw stack trace.
136
+ function readReportOrExit(reportPath, cmdLabel) {
137
+ let text;
138
+ try { text = readFileSync(reportPath, 'utf8'); } catch {
139
+ console.error(`${cmdLabel}: no report.json in ${dirname(reportPath)}`);
140
+ process.exit(2);
141
+ }
142
+ try { return JSON.parse(text); } catch {
143
+ console.error(`${cmdLabel}: report.json in ${dirname(reportPath)} is not valid JSON`);
144
+ process.exit(2);
145
+ }
146
+ }
147
+
148
+ // `council doctor` answers "can this actually run, right now, on this
149
+ // machine" for a stranger who just installed it - which keys are present (by
150
+ // name only, never the value), which shipped chains are runnable with those
151
+ // keys (or need nothing at all: mock and external seats never need a key),
152
+ // and the worst-case price of each. Zero network calls: this only reads env
153
+ // var names and chains/*.json and does the same static pricing math as
154
+ // --dry-run (estimateChainRows, shared from cost.js, not re-derived here).
155
+ if (argv[0] === 'doctor') {
156
+ // `council doctor --run <folder>` (v5 §1 candidate 3): read-only check of
157
+ // one run's withdrawal chains for a section that ended up with no
158
+ // surviving owner - recomputed from proposals on disk rather than
159
+ // trusting a stored field, so it still catches an older report.json
160
+ // written before orphanSections existed.
161
+ const runArg = flag('run', null);
162
+ if (runArg) {
163
+ const runDir = resolve(work, runArg);
164
+ const reportPath = join(runDir, 'report.json');
165
+ const report = readReportOrExit(reportPath, 'council doctor --run');
166
+ const ledger = withdrawalLedger(report.proposals || []);
167
+ if (ledger.orphanSections.length) {
168
+ console.error(`withdrawal cycle detected: ${ledger.withdrawalCycles} cycle(s), ${ledger.orphanSections.length} orphaned proposal(s) with no surviving owner: ${ledger.orphanSections.join(', ')}`);
169
+ process.exit(1);
170
+ }
171
+ console.log(`No orphaned withdrawal chains in ${runDir}.`);
172
+ process.exit(0);
173
+ }
174
+
175
+ // `council doctor --chain <file>` (v5 §1 candidate 5): fail-loud lint of
176
+ // one chain config - missing stage contracts, unreachable stages, and
177
+ // unrecognized provider references - before anything is ever run against
178
+ // it. Takes a path (own chains/ or a shipped one), not a bare chain name,
179
+ // so a chain still being drafted (not yet named/installed) can be linted.
180
+ const chainArg = flag('chain', null);
181
+ if (chainArg) {
182
+ const chainPath = resolve(work, chainArg);
183
+ if (!existsSync(chainPath)) {
184
+ console.error(`council doctor --chain: no such file ${chainPath}`);
185
+ process.exit(2);
186
+ }
187
+ let cfg;
188
+ try { cfg = JSON.parse(readFileSync(chainPath, 'utf8')); } catch {
189
+ console.error(`council doctor --chain: ${chainPath} is not valid JSON`);
190
+ process.exit(2);
191
+ }
192
+ const findings = lintChain(cfg, chainPath);
193
+ if (findings.length) {
194
+ console.error(`\n${chainPath}: ${findings.length} problem(s)\n`);
195
+ for (const f of findings) {
196
+ console.error(` [${f.kind}] ${f.message}`);
197
+ console.error(` fix: ${f.fix}\n`);
198
+ }
199
+ process.exit(1);
200
+ }
201
+ console.log(`${chainPath}: no lint problems.`);
202
+ process.exit(0);
203
+ }
204
+
205
+ // `council doctor --scan-artifacts` (v5 §1 candidate 11): a key-shaped string pasted into a
206
+ // task file or chain config is the one thing that can turn BYOK into a leaked secret, and this
207
+ // repo is public. Scans the user's own task files and chain configs; never prints the matched
208
+ // text, only where it was found.
209
+ if (argv.includes('--scan-artifacts')) {
210
+ const roots = [join(work, 'tasks'), join(work, 'chains'), join(pkg, 'chains')].filter(existsSync);
211
+ const { findings, filesScanned } = scanArtifacts(roots);
212
+ if (findings.length) {
213
+ console.error(`key-redaction scan: ${findings.length} possible key(s) found across ${filesScanned} file(s) scanned:`);
214
+ for (const f of findings) console.error(` ${f.file}:${f.line}:${f.column} - looks like ${f.pattern} (value not shown)`);
215
+ console.error(`\nRemove or rotate any real key found above before committing or sharing these files.`);
216
+ process.exit(1);
217
+ }
218
+ console.log(`key-redaction scan: no key-shaped strings found across ${filesScanned} file(s) in ${roots.join(', ')}.`);
219
+ process.exit(0);
220
+ }
221
+
222
+ console.log(`\nAPI keys (name only - the value is never read here beyond presence/absence):`);
223
+ const names = providerNames();
224
+ const nw = Math.max(...names.map(n => n.length));
225
+ const present = new Set();
226
+ for (const name of names) {
227
+ const envName = envKeyName(name);
228
+ const has = !!process.env[envName];
229
+ if (has) present.add(name);
230
+ console.log(` ${name.padEnd(nw)} ${envName.padEnd(20)} ${has ? 'set' : 'not set'}`);
231
+ }
232
+
233
+ console.log(`\nChains (runnable = every seat's provider has its key set, or is mock/external):`);
234
+ const chainsDir = join(pkg, 'chains');
235
+ const files = readdirSync(chainsDir).filter(f => f.endsWith('.json')).sort();
236
+ const cw = Math.max(...files.map(f => f.length - 5));
237
+ for (const f of files) {
238
+ const cfg = JSON.parse(readFileSync(join(chainsDir, f), 'utf8'));
239
+ const seats = [
240
+ cfg.seats.criteria, cfg.seats.builder, cfg.seats.reviser, cfg.seats.finalist,
241
+ cfg.seats.skeleton, cfg.seats.handoff, cfg.seats.questions, cfg.seats.judge,
242
+ ...(cfg.seats.proposers || []), ...(cfg.seats.critics || []),
243
+ ].filter(Boolean);
244
+ const missing = checkSeats(seats);
245
+ const runnable = missing.length === 0;
246
+ const worst = estimateChainRows(cfg).reduce((sum, r) => sum + r.usd, 0);
247
+ const label = f.replace(/\.json$/, '');
248
+ console.log(` ${label.padEnd(cw)} ${(runnable ? 'runnable' : 'blocked ').padEnd(8)} worst-case ${formatUsd(worst).padStart(9)}/run${runnable ? '' : ` (missing: ${missing.join(', ')})`}`);
249
+ // v5 §1 candidate 13: warn, never fail - an old chain file is read exactly as it always was.
250
+ const warning = schemaVersionWarning(cfg);
251
+ if (warning) console.log(` schemaVersion: ${warning.split('\n').join('\n ')}`);
252
+ }
253
+ console.log(`\nNo network calls were made - this only reads environment variable names and chains/*.json.`);
254
+
255
+ // v5 §1 candidate 4: doctor's own diagnostic block also names every code
256
+ // a stranger might see from a hard-fail path, so a code seen in a
257
+ // terminal is greppable straight back to this same command.
258
+ console.log(`\nError codes (see TROUBLESHOOTING.md for the full message and fix for each):`);
259
+ for (const [code, entry] of Object.entries(ERROR_CATALOG)) {
260
+ console.log(` ${code} [${entry.kind}] ${entry.title}`);
261
+ }
262
+ process.exit(0);
263
+ }
264
+
265
+ // `council demo` runs a $0, offline, no-keys-needed chain end to end with the
266
+ // mock provider (calls nothing real) and prints the resulting deliverable and
267
+ // debate board to stdout, so a stranger who just installed this can see the
268
+ // whole mechanism - proposals, anonymised debate, panel sign-off - work
269
+ // before ever touching a real key.
270
+ if (argv[0] === 'demo') {
271
+ const demoChainName = 'mock-debate';
272
+ const demoConfig = JSON.parse(readFileSync(join(pkg, 'chains', `${demoChainName}.json`), 'utf8'));
273
+ console.log(`\nThe High Council - offline demo (chain: ${demoChainName}, provider: mock)`);
274
+ console.log(`No API key, no network call, $0. This is what the mechanism looks like end to end.\n`);
275
+ const demoResult = await runChain({
276
+ request: 'Demo request. The mock provider ignores this text - see chains/mock-debate.json for what it always returns.',
277
+ config: demoConfig,
278
+ log: line => console.log(line),
279
+ });
280
+ console.log(`\n${'='.repeat(72)}\nDELIVERABLE\n${'='.repeat(72)}\n`);
281
+ console.log(demoResult.deliverable);
282
+ if (demoResult.board) {
283
+ console.log(`\n${'='.repeat(72)}\nDEBATE BOARD\n${'='.repeat(72)}\n`);
284
+ console.log(demoResult.board);
285
+ }
286
+ if (demoResult.handoff) {
287
+ console.log(`\n${'='.repeat(72)}\nHANDOFF\n${'='.repeat(72)}\n`);
288
+ console.log(demoResult.handoff);
289
+ }
290
+ console.log(`\nThat was $0 and touched no network - the mock provider calls nothing real.`);
291
+ console.log(`Next: "council doctor" to see which real chains you can run with your own keys,`);
292
+ console.log(`or "council --chain verify --dry-run" to price a real run before spending anything.`);
293
+ process.exit(0);
294
+ }
295
+
296
+ // `council init [--yes]` (v5 §1 candidate 6): the gap between "the demo
297
+ // ran" and "I can run my own task" is where a clone gets abandoned -
298
+ // `doctor` says what's broken and `demo` shows output, but neither
299
+ // leaves a stranger with a chain file they own and understand. This
300
+ // does: doctor's key check, a starter chain + task file written to the
301
+ // user's own directory, a real (no-network) price of that starter
302
+ // chain, then a canned, all-mock, 3-seat run executed end to end so the
303
+ // first artifact a stranger inspects is a real run folder, not just
304
+ // terminal output. `--yes` skips nothing interactive - there is nothing
305
+ // interactive to skip - it only exists so a script can invoke this
306
+ // without wondering whether it will ever prompt.
307
+ if (argv[0] === 'init') {
308
+ console.log(`\ncouncil init - a first chain and task you own, plus one real run to look at.\n`);
309
+
310
+ console.log(`API keys (name only - the value is never read here beyond presence/absence):`);
311
+ const names = providerNames();
312
+ const nw = Math.max(...names.map(n => n.length));
313
+ let anyKeySet = false;
314
+ for (const name of names) {
315
+ const envName = envKeyName(name);
316
+ const has = !!process.env[envName];
317
+ if (has) anyKeySet = true;
318
+ console.log(` ${name.padEnd(nw)} ${envName.padEnd(20)} ${has ? 'set' : 'not set - a chain using this lab would abort (COUNCIL-E001), not degrade'}`);
319
+ }
320
+
321
+ const chainsDir = join(work, 'chains');
322
+ const tasksDir = join(work, 'tasks');
323
+ mkdirSync(chainsDir, { recursive: true });
324
+ mkdirSync(tasksDir, { recursive: true });
325
+
326
+ // JSON has no comment syntax, so "commented" here means the same
327
+ // "_note" convention src/pricing.json already uses: a real, readable
328
+ // field that JSON.parse happily ignores as unused chain config.
329
+ const starterChain = {
330
+ _note: 'Your first chain. One lab writes a plan, another lab reviews it once. Edit the seats below to use whichever labs you have keys for - see README.md#chains for the full field list.',
331
+ name: 'my-first-chain',
332
+ description: 'A minimal real chain: one builder, one critic, one round.',
333
+ maxRounds: 1,
334
+ seats: {
335
+ criteria: { _note: 'Writes the acceptance criteria before anything else exists.', provider: 'anthropic', model: 'claude-sonnet-5', maxTokens: 2000 },
336
+ builder: { _note: 'Writes the first draft.', provider: 'anthropic', model: 'claude-sonnet-5', maxTokens: 8000 },
337
+ critics: [{ _note: 'Reviews the draft against the criteria. Add a second seat here for a real second opinion.', provider: 'anthropic', model: 'claude-sonnet-5', maxTokens: 8000 }],
338
+ },
339
+ };
340
+ // Never overwrite a file the user was told to edit - the starter chain's
341
+ // own _note says "edit the seats below", and a silent overwrite on a
342
+ // rerun would erase exactly that edit with no warning.
343
+ const starterChainPath = join(chainsDir, 'my-first-chain.json');
344
+ if (existsSync(starterChainPath)) {
345
+ console.log(`\n${starterChainPath} already exists - left as-is.`);
346
+ } else {
347
+ writeFileSync(starterChainPath, JSON.stringify(starterChain, null, 2));
348
+ console.log(`\nWrote ${starterChainPath}`);
349
+ }
350
+
351
+ const starterTaskPath = join(tasksDir, 'my-first-task.md');
352
+ if (existsSync(starterTaskPath)) {
353
+ console.log(`${starterTaskPath} already exists - left as-is.`);
354
+ } else {
355
+ writeFileSync(starterTaskPath, `A short plan for a personal weekly reading list: what it tracks, how an entry gets added, how "done" is marked. Plain prose - this file is read by the criteria stage as-is, nothing else parses it.\n`);
356
+ console.log(`Wrote ${starterTaskPath}`);
357
+ }
358
+
359
+ console.log(`\nPrice of your starter chain (no network call, no key needed for this step):`);
360
+ const rows = estimateChainRows(starterChain);
361
+ const w = Math.max(...rows.map(r => r.seat.length));
362
+ for (const r of rows) {
363
+ console.log(` ${r.label.padEnd(12)} ${r.seat.padEnd(w)} ${r.priced ? formatUsd(r.usd) : 'unpriced'}`);
364
+ }
365
+ const worst = rows.reduce((s, r) => s + r.usd, 0);
366
+ console.log(` worst case: ${formatUsd(worst)} for the whole chain`);
367
+
368
+ console.log(`\nRunning a canned, offline, $0 task end to end so you can see a real run folder before touching a key:`);
369
+ const cannedConfig = {
370
+ name: 'council-init-demo',
371
+ maxRounds: 1,
372
+ seats: {
373
+ criteria: { provider: 'mock', model: 'mock-criteria' },
374
+ builder: { provider: 'mock', model: 'mock-builder' },
375
+ critics: [{ provider: 'mock', model: 'mock-critic-a' }],
376
+ },
377
+ };
378
+ const initRunId = `${new Date().toISOString().replace(/[:.]/g, '-')}-init`;
379
+ const initRunDir = join(work, 'runs', initRunId);
380
+ mkdirSync(initRunDir, { recursive: true });
381
+ const initResult = await runChain({
382
+ request: readFileSync(starterTaskPath, 'utf8'),
383
+ config: cannedConfig,
384
+ log: line => console.log(` ${line}`),
385
+ });
386
+ writeFileSync(join(initRunDir, 'deliverable.md'), initResult.deliverable);
387
+ writeFileSync(join(initRunDir, 'report.json'), JSON.stringify(reportJsonShape({
388
+ runId: initRunId, chain: cannedConfig.name, task: starterTaskPath, result: initResult, config: cannedConfig,
389
+ }), null, 2));
390
+
391
+ console.log(`\nWrote ${initRunDir} - a real run folder (report.json, deliverable.md) from the canned demo task, $0, no network call.`);
392
+ console.log(`\nNext, with a real key set: node src/cli.js --chain my-first-chain --task ${starterTaskPath.replace(work + '/', '')} --dry-run`);
393
+ console.log(`Then, for real: node src/cli.js --chain my-first-chain --task ${starterTaskPath.replace(work + '/', '')}`);
394
+ if (!anyKeySet) console.log(`\nNo API keys are set yet - see README.md#setup, or "council doctor" any time to re-check.`);
395
+ process.exit(0);
396
+ }
397
+
398
+ // `council export-board --run <folder> --out <file>` (v5 §1 candidate 8):
399
+ // a run's proposals/debate/replies/verdict as one self-contained HTML
400
+ // file - no external assets, no template-engine dependency, no server.
401
+ if (argv[0] === 'export-board') {
402
+ const runArg = flag('run', null);
403
+ const outArg = flag('out', 'board.html');
404
+ if (!runArg) {
405
+ console.error('export-board: --run <folder> is required');
406
+ process.exit(2);
407
+ }
408
+ const runDir = resolve(work, runArg);
409
+ const reportPath = join(runDir, 'report.json');
410
+ const report = readReportOrExit(reportPath, 'export-board');
411
+ const outPath = resolve(work, outArg);
412
+ writeFileSync(outPath, renderBoardHtml(report));
413
+ console.log(`Wrote ${outPath}`);
414
+ process.exit(0);
415
+ }
416
+
417
+ // `council replay --run <folder> [--json]` (v5 §1 candidate 9): a
418
+ // numbered, indented, step-by-step transcript of a run's reasoning - a
419
+ // CLI-native complement to export-board's shareable HTML artifact.
420
+ if (argv[0] === 'replay') {
421
+ const runArg = flag('run', null);
422
+ if (!runArg) {
423
+ console.error('replay: --run <folder> is required');
424
+ process.exit(2);
425
+ }
426
+ const runDir = resolve(work, runArg);
427
+ const reportPath = join(runDir, 'report.json');
428
+ const report = readReportOrExit(reportPath, 'replay');
429
+ const steps = buildTranscript(report);
430
+ if (argv.includes('--json')) {
431
+ console.log(JSON.stringify(steps, null, 2));
432
+ } else {
433
+ console.log(renderTranscriptText(steps));
434
+ }
435
+ process.exit(0);
436
+ }
437
+
438
+ // `council --spend [--days N]` answers "what have I spent across every run",
439
+ // which neither run_status (one run) nor the per-run cap (one run) can.
440
+ if (argv.includes('--spend')) {
441
+ const days = Number(flag('days', 1));
442
+ if (!Number.isFinite(days) || days <= 0) {
443
+ console.error('--days: expected a positive number of days');
444
+ process.exit(2);
445
+ }
446
+ const r = spendReport(join(work, 'runs'), { days });
447
+ console.log(`\nSpend across ${r.count} run(s) since ${r.since.toISOString().slice(0, 16).replace('T', ' ')} (${join(work, 'runs')})`);
448
+ if (!r.count) {
449
+ console.log(` no runs in this window.${r.note ? ` ${r.note}` : ''}`);
450
+ } else {
451
+ const w = Math.max(...r.runs.map(x => (x.chain || '?').length));
452
+ for (const x of r.runs) {
453
+ console.log(` ${x.id} ${(x.chain || '?').padEnd(w)} ${formatUsd(x.usd).padStart(9)} ${x.state}`);
454
+ }
455
+ console.log(` ${''.padEnd(24)} ${''.padEnd(w)} ${formatUsd(r.totalUsd).padStart(9)} total`);
456
+ if (r.runs.some(x => !x.complete)) console.log(`\n Runs still going or stopped short are counted from the stages they already paid for.`);
457
+ }
458
+ if (r.unreadable) console.log(` ${r.unreadable} run folder(s) could not be read and are not counted.`);
459
+ console.log(`\n Derived from the run folders on disk. Nothing is recorded anywhere else, and nothing leaves this machine.`);
460
+ process.exit(0);
461
+ }
462
+
463
+ // `council --cost-today [--date YYYY-MM-DD]` answers "what have I spent today", by the
464
+ // local calendar day rather than --spend's rolling 24h window, with a per-model breakdown
465
+ // across every run counted - the granularity v2 plan §8 wanted a ledger file for, derived
466
+ // instead from the same run-folder files --spend already reads (see DECISIONS.md).
467
+ if (argv.includes('--cost-today')) {
468
+ const dateArg = flag('date', null);
469
+ const date = dateArg && dateArg !== true ? new Date(`${dateArg}T00:00:00`) : new Date();
470
+ if (Number.isNaN(date.getTime())) {
471
+ console.error('--date: expected YYYY-MM-DD');
472
+ process.exit(2);
473
+ }
474
+ const r = costToday(join(work, 'runs'), { date });
475
+ // r.date is local midnight; toISOString() converts to UTC and can print the wrong
476
+ // calendar day in any non-UTC timezone, so format from local parts instead.
477
+ const dayLabel = `${r.date.getFullYear()}-${String(r.date.getMonth() + 1).padStart(2, '0')}-${String(r.date.getDate()).padStart(2, '0')}`;
478
+ console.log(`\nSpend on ${dayLabel} across ${r.count} run(s) (${join(work, 'runs')})`);
479
+ if (!r.count) {
480
+ console.log(` no runs on this day.${r.note ? ` ${r.note}` : ''}`);
481
+ } else {
482
+ const w = Math.max(...r.runs.map(x => (x.chain || '?').length));
483
+ for (const x of r.runs) {
484
+ console.log(` ${x.id} ${(x.chain || '?').padEnd(w)} ${formatUsd(x.usd).padStart(9)} ${x.state}`);
485
+ }
486
+ console.log(` ${''.padEnd(24)} ${''.padEnd(w)} ${formatUsd(r.totalUsd).padStart(9)} total`);
487
+ if (r.perModel.length) {
488
+ console.log(`\n By model:`);
489
+ for (const m of r.perModel) console.log(` ${m.model.padEnd(40)} ${formatUsd(m.usd).padStart(9)}`);
490
+ }
491
+ }
492
+ if (r.unreadable) console.log(` ${r.unreadable} run folder(s) could not be read and are not counted.`);
493
+ console.log(`\n Derived from the run folders on disk. Nothing is recorded anywhere else, and nothing leaves this machine.`);
494
+ process.exit(0);
495
+ }
496
+
497
+ // `council --stats [--days N]` answers "how is the debate mechanism itself
498
+ // doing" - sign-off rate, rounds, objections, cost and the biggest prompt
499
+ // files - per chain and per lab, across every run on disk.
500
+ if (argv.includes('--stats')) {
501
+ const days = Number(flag('days', 30));
502
+ if (!Number.isFinite(days) || days <= 0) {
503
+ console.error('--days: expected a positive number of days');
504
+ process.exit(2);
505
+ }
506
+ const r = verdictStats(join(work, 'runs'), { days });
507
+ console.log(`\nVerdict stats across ${r.runsSeen} run(s) since ${r.since.toISOString().slice(0, 16).replace('T', ' ')} (${join(work, 'runs')})`);
508
+ if (!r.chains.length) {
509
+ console.log(` no completed runs in this window.${r.note ? ` ${r.note}` : ''}`);
510
+ } else {
511
+ console.log(`\nBy chain:`);
512
+ const w = Math.max(...r.chains.map(c => c.chain.length));
513
+ for (const c of r.chains) {
514
+ console.log(` ${c.chain.padEnd(w)} runs=${c.runs} signoff=${c.signoffRate === null ? '-' : `${Math.round(c.signoffRate * 100)}%`}` +
515
+ ` rounds=${c.meanRoundsToSignoff === null ? '-' : c.meanRoundsToSignoff.toFixed(1)}` +
516
+ ` objections=${c.objections} withdrawn=${c.withdrawals} accepted=${c.accepted}` +
517
+ ` dropouts=${c.dropouts} unparseable=${c.unparseable} shapeOnlyRounds=${c.shapeOnlyRounds}` +
518
+ ` cost=${c.meanCostUsd === null ? '-' : formatUsd(c.meanCostUsd)}` +
519
+ ` wall=${c.meanWallMs === null ? '-' : `${Math.round(c.meanWallMs / 1000)}s`}`);
520
+ }
521
+ console.log(`\nBy lab:`);
522
+ for (const l of r.labs) {
523
+ console.log(` ${l.lab}: proposed=${l.proposed} accepted=${l.accepted} withdrawn=${l.withdrawn} cut=${l.cut} dropouts=${l.dropouts} unparseable=${l.unparseable}`);
524
+ }
525
+ console.log(`\nIndependence skew (novel-objection rate, solo-signoff rate - descriptive only, never auto-reweighted):`);
526
+ for (const l of r.labs) {
527
+ const novel = l.novelObjectionRate === null ? '-' : `${Math.round(l.novelObjectionRate * 100)}%`;
528
+ const solo = l.soloSignoffRate === null ? '-' : `${Math.round(l.soloSignoffRate * 100)}%`;
529
+ console.log(` ${l.lab}: novelObjections=${novel} (${l.novelObjections}/${l.objections}) soloSignoff=${solo} (${l.soloSignoffs}/${l.signoffs})${l.lowIndependence ? ' [low independence]' : ''}`);
530
+ }
531
+ if (argv.includes('--csv')) {
532
+ console.log(`\nIndependence skew CSV:`);
533
+ console.log(independenceStatsCsv(r.labs));
534
+ }
535
+ if (r.largestPrompts.length) {
536
+ console.log(`\nLargest prompt file per stage type:`);
537
+ for (const p of r.largestPrompts.slice(0, 10)) {
538
+ console.log(` ${p.stageType}: ${p.file} (${p.run}) - ${(p.bytes / 1024).toFixed(1)}KB, ~${p.tokensApprox} tokens`);
539
+ }
540
+ }
541
+ }
542
+ if (r.unreadable) console.log(`\n ${r.unreadable} run folder(s) could not be read and are not counted.`);
543
+ console.log(`\n Derived from the run folders on disk. Nothing is recorded anywhere else, and nothing leaves this machine.`);
544
+ process.exit(0);
545
+ }
546
+
547
+ // `council --forecast-cost --chain <name>` (v5 §1 candidate 7): a
548
+ // realistic-case range from this chain's own history on this machine,
549
+ // repriced at today's pricing.json - distinct from `--dry-run`'s
550
+ // worst-case estimate from the chain's declared token assumptions.
551
+ if (argv.includes('--forecast-cost')) {
552
+ const chainNameArg = flag('chain', 'verify');
553
+ const days = Number(flag('days', 90));
554
+ if (!Number.isFinite(days) || days <= 0) {
555
+ console.error('--days: expected a positive number of days');
556
+ process.exit(2);
557
+ }
558
+ const f = forecastCost(chainNameArg, join(work, 'runs'), { days });
559
+ console.log(`\nCost forecast for chain "${f.chain}", from ${f.runsUsed} historical run(s) in the last ${f.days} day(s):`);
560
+ if (f.low === null) {
561
+ console.log(` no estimate - ${f.note}. Try "council --chain ${f.chain} --dry-run" for a worst-case estimate with no history needed.`);
562
+ } else {
563
+ console.log(` ${formatUsd(f.low)} - ${formatUsd(f.high)} (mean ${formatUsd(f.mean)})`);
564
+ console.log(` This is a range from what this chain has actually cost before, repriced at today's rates - not a promise. A run outside this range is possible.`);
565
+ if (f.partial) console.log(` PARTIAL: ${f.note}`);
566
+ }
567
+ process.exit(0);
568
+ }
569
+
570
+ // `council --mcp` starts the MCP server instead of running a chain. The
571
+ // package ships one bin, so this is what makes a single npx invocation work
572
+ // as an MCP command: `npx -y the-high-council --mcp`. Checked before any
573
+ // other argument handling, since the server takes none of them.
574
+ if (argv.includes('--mcp')) {
575
+ await import('./mcp/server.js');
576
+ } else {
577
+
578
+ const chainName = flag('chain', 'verify');
579
+ const taskPath = flag('task', null);
580
+ const fromRun = flag('from-run', null);
581
+ const resumeRun = flag('resume', null);
582
+ const dryRun = argv.includes('--dry-run');
583
+
584
+ // Per-run spend ceiling. A BYOK tool that a stranger points their own API
585
+ // keys at ships with a ceiling ON by default; --max-usd none is the explicit
586
+ // way to run without one, and says so in the log.
587
+ const DEFAULT_MAX_USD = 5;
588
+ const maxUsdArg = flag('max-usd', process.env.MAX_USD_PER_RUN ?? String(DEFAULT_MAX_USD));
589
+ let maxUsd;
590
+ if (maxUsdArg === true) { console.error('--max-usd: needs a value, e.g. --max-usd 2 or --max-usd none'); process.exit(2); }
591
+ else if (maxUsdArg === 'none' || maxUsdArg === 'off' || maxUsdArg === '0') maxUsd = null;
592
+ else {
593
+ maxUsd = Number(maxUsdArg);
594
+ if (!Number.isFinite(maxUsd) || maxUsd < 0) {
595
+ console.error(`--max-usd: expected a number of dollars or "none", got "${maxUsdArg}"`);
596
+ process.exit(2);
597
+ }
598
+ }
599
+
600
+ if (argv.includes('--help') || (!taskPath && !dryRun && !resumeRun)) {
601
+ console.log(`The High Council - a chained multi-model harness
602
+
603
+ council demo $0, offline, no keys needed: run a full
604
+ mock chain (proposals, debate, sign-off)
605
+ and print the deliverable and board
606
+ council doctor which API keys you have (names only),
607
+ which chains you can run with them, and
608
+ each one's worst-case price. No network call.
609
+ council --task tasks/example.md [--chain verify|seven] [--rounds N]
610
+ council --task tasks/x.md --chain plan-relay --from-run runs/<earlier run>
611
+ reuse that run's criteria and first draft,
612
+ so two panels can be compared on one draft
613
+ council --task tasks/x.md --chain plan-unanimous --from-run runs/<r> --draft file.md --rounds 1
614
+ panel-only: grade this draft against that run's criteria
615
+ council --task tasks/x.md --chain plan-debate --context context/war-of-love
616
+ append standing direction docs to the request
617
+ council --resume runs/<r> continue a run that paused at an external seat
618
+ (after writing runs/<r>/<label>.md); completed
619
+ stages replay from disk and cost nothing
620
+ council --chain seven --dry-run estimate tokens and cost, call nothing
621
+ council --spend [--days 7] what every run has cost, across runs,
622
+ read back off disk. Nothing is recorded
623
+ and nothing leaves this machine.
624
+ council --cost-today [--date Y-M-D] what has been spent today, by calendar day,
625
+ with a per-model breakdown. Same disk-only source.
626
+ council --stats [--days 30] how the debate mechanism itself is doing:
627
+ sign-off rate, rounds, objections, dropouts,
628
+ cost/wall time and the biggest prompt files,
629
+ per chain and per lab. Same disk-only source.
630
+ council --task tasks/x.md --max-usd 2 stop the run before any stage that could
631
+ take it past $2. Default $5, or
632
+ MAX_USD_PER_RUN. --max-usd none disables
633
+ the ceiling. A stopped run resumes with
634
+ --resume and a higher ceiling; stages
635
+ already on disk replay for free.
636
+
637
+ Chains live in chains/*.json. Runs are written to runs/<timestamp>/.
638
+ The 'relay' command is kept as an alias for 'council'; both run this file.`);
639
+ // Asking for --help is success (exit 0) even with no task given; landing
640
+ // here with neither --help nor a task/dry-run/resume is the error case
641
+ // (exit 1) - these used to be conflated into one `taskPath ? 0 : 1`, which
642
+ // made a bare `--help` exit 1.
643
+ process.exit(argv.includes('--help') ? 0 : 1);
644
+ }
645
+
646
+ // --resume: everything about the run comes from its own run.json.
647
+ let resumeMeta = null;
648
+ if (resumeRun) {
649
+ resumeMeta = JSON.parse(readFileSync(join(resolve(resumeRun), 'run.json'), 'utf8'));
650
+ }
651
+ const chainNameEff = resumeMeta?.chain || chainName;
652
+ // A user's own chains/ takes precedence, so a custom chain works from an
653
+ // npm install without editing anything inside node_modules.
654
+ const configPath = [join(work, 'chains', `${chainNameEff}.json`), join(pkg, 'chains', `${chainNameEff}.json`)]
655
+ .find(existsSync) || join(pkg, 'chains', `${chainNameEff}.json`);
656
+ if (!existsSync(configPath)) {
657
+ console.error(`No such chain: ${configPath}`);
658
+ process.exit(1);
659
+ }
660
+ // v5 §1 candidate 4, COUNCIL-E003: a malformed chain file is a fatal,
661
+ // stranger-facing condition - caught here rather than left to an
662
+ // uncaught SyntaxError. Handled inline (not left to the later try/catch
663
+ // around the run itself, which starts well after this point) so the
664
+ // tone-shaped message prints even though nothing has been invoked yet.
665
+ let config;
666
+ try {
667
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
668
+ } catch {
669
+ console.error(`\n${formatCouncilError('COUNCIL-E003', { path: configPath })}`);
670
+ process.exit(EXIT_FATAL);
671
+ }
672
+ if (flag('rounds', null)) config.maxRounds = Number(flag('rounds'));
673
+
674
+ // v5 §1 candidate 5: fail loud, before a single metered call, on a chain
675
+ // config that is broken rather than merely risky - distinct from
676
+ // preflightCheck's warn-never-block posture below, which is about task
677
+ // text, not chain config validity. Skipped on --resume: the chain already
678
+ // ran a first round successfully, so its config already proved runnable.
679
+ if (!resumeMeta) {
680
+ const lintFindings = lintChain(config, configPath);
681
+ if (lintFindings.length) {
682
+ console.error(`\nchain lint: ${configPath} has ${lintFindings.length} problem(s) and will not run:\n`);
683
+ for (const f of lintFindings) {
684
+ console.error(` [${f.kind}] ${f.message}`);
685
+ console.error(` fix: ${f.fix}\n`);
686
+ }
687
+ process.exit(1);
688
+ }
689
+ }
690
+
691
+ // --from-run: the earlier run's criteria and first draft are reused verbatim,
692
+ // so whatever differs in the outcome is the panel, not a fresh coin toss.
693
+ let handedDraft = null;
694
+ if (fromRun) {
695
+ // A run that crashed has no report.json but does have criteria.md - the
696
+ // criteria stage's raw reply - so the criteria can still be reused.
697
+ const reportPath = join(resolve(fromRun), 'report.json');
698
+ if (existsSync(reportPath)) {
699
+ config.criteria = JSON.parse(readFileSync(reportPath, 'utf8')).criteria;
700
+ } else {
701
+ const raw = readFileSync(join(resolve(fromRun), 'criteria.md'), 'utf8');
702
+ config.criteria = JSON.parse(raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1)).criteria;
703
+ }
704
+ handedDraft = readFileSync(join(resolve(fromRun), 'build.md'), 'utf8');
705
+ }
706
+ // --draft <file>: review this exact text instead of building one. With
707
+ // --from-run it replaces that run's build.md; the criteria still come from
708
+ // the run. With --rounds 1 this is a panel-only pass: no builder, no reviser.
709
+ const draftPath = resumeMeta ? resumeMeta.draft : flag('draft', null);
710
+ if (draftPath) handedDraft = readFileSync(resolve(work, draftPath), 'utf8');
711
+ if (resumeMeta?.fromRun && !fromRun) {
712
+ const rp = join(resolve(work, resumeMeta.fromRun), 'report.json');
713
+ if (existsSync(rp)) config.criteria = JSON.parse(readFileSync(rp, 'utf8')).criteria;
714
+ }
715
+
716
+ const allSeats = [
717
+ config.seats.criteria,
718
+ config.seats.builder,
719
+ config.seats.reviser,
720
+ config.seats.finalist,
721
+ config.seats.skeleton,
722
+ config.seats.handoff,
723
+ config.seats.questions,
724
+ ...(config.seats.proposers || []),
725
+ ...(config.seats.critics || []),
726
+ ].filter(Boolean);
727
+
728
+ if (dryRun) {
729
+ // A dry run prices the chain from the config's own declared token
730
+ // assumptions. It calls nothing, so it costs nothing.
731
+ console.log(`\nChain: ${config.name} - ${config.description}`);
732
+ console.log(`Rounds: ${config.maxRounds}\n`);
733
+ const rows = estimateChainRows(config, { fromRun });
734
+ const w = Math.max(...rows.map(r => r.seat.length));
735
+ for (const r of rows) {
736
+ console.log(` ${r.label.padEnd(12)} ${r.seat.padEnd(w)} ${String(r.input).padStart(7)} in ${String(r.output).padStart(6)} out ${r.priced ? formatUsd(r.usd) : 'unpriced'}`);
737
+ }
738
+ const t = rows.reduce((s, r) => ({ i: s.i + r.input, o: s.o + r.output, u: s.u + r.usd }), { i: 0, o: 0, u: 0 });
739
+ console.log(`\n TOTAL ${''.padEnd(w)} ${String(t.i).padStart(7)} in ${String(t.o).padStart(6)} out ${formatUsd(t.u)} per run`);
740
+ console.log(`\n Worst case is the full round cap. A clean first critique stops early and costs less.`);
741
+ // mock/external are synthetic seats that are never billed and were never
742
+ // going to be in pricing.json - only a real provider's missing price is
743
+ // worth telling anyone about.
744
+ const unpriced = [...new Set(rows.filter(r => !r.priced && !r.seat.startsWith('mock/') && !r.seat.startsWith('external/')).map(r => r.seat))];
745
+ if (unpriced.length) {
746
+ console.log('');
747
+ for (const seat of unpriced) {
748
+ const [provider, model] = seat.split('/');
749
+ console.log(` ${formatCouncilError('COUNCIL-E002', { provider, model }).replace(/\n/g, '\n ')}`);
750
+ }
751
+ }
752
+ process.exit(0);
753
+ }
754
+
755
+ const missing = checkSeats(allSeats);
756
+ if (missing.length) {
757
+ // v5 §1 candidate 4, COUNCIL-E001: one tone-shaped message per missing
758
+ // provider, deduplicated - a chain with three seats on the same
759
+ // unset key gets told once, not three times.
760
+ const missingProviders = [...new Set(allSeats.filter(Boolean).map(s => s.provider).filter(p => !keyFor(p)))];
761
+ console.error('');
762
+ for (const provider of missingProviders) {
763
+ console.error(formatCouncilError('COUNCIL-E001', { provider, chain: config.name, envVar: envKeyName(provider) }));
764
+ console.error('');
765
+ }
766
+ console.error(`Run "council doctor" to see exactly which keys each shipped chain needs.`);
767
+ process.exit(EXIT_DEGRADABLE);
768
+ }
769
+
770
+ const taskPathEff = resumeMeta?.task || taskPath;
771
+ const taskFile = resolve(work, taskPathEff);
772
+ if (!existsSync(taskFile)) {
773
+ console.error(`\nNo task file at ${taskFile}`);
774
+ console.error(`The task file is the request the council plans against - plain prose, written by you.`);
775
+ console.error(`Create one and run again, e.g.:\n`);
776
+ console.error(` mkdir -p tasks`);
777
+ console.error(` echo "What I want planned, in plain words." > ${taskPathEff}`);
778
+ process.exit(1);
779
+ }
780
+ let request = readFileSync(taskFile, 'utf8');
781
+ // v2 plan §7.2: fingerprint scope is the task's own text and the chain config, not the
782
+ // --context document bundle appended below - captured before that append happens.
783
+ const rawTaskTextForCacheFingerprint = request;
784
+ // v3 §2: frozen-scope enforcement. Hashes the raw task text alone (not the chain config -
785
+ // that's §7.2's cache fingerprint, a different property) so a run can tell whether its own
786
+ // task file changed since it started, independent of --context docs. Reuses src/integrity.js's
787
+ // own hashing rather than a second implementation.
788
+ const taskHash = taskHashOf(rawTaskTextForCacheFingerprint);
789
+ // --context: standing direction documents, appended to every request so the
790
+ // harness plans within the same direction the humans discuss (context/README.md).
791
+ const contextArg = resumeMeta ? resumeMeta.context : flag('context', null);
792
+ if (contextArg) {
793
+ const { readdirSync, statSync } = await import('node:fs');
794
+ const files = [];
795
+ for (const entry of String(contextArg).split(',').map(x => x.trim()).filter(Boolean)) {
796
+ const p = resolve(entry);
797
+ if (statSync(p).isDirectory()) {
798
+ for (const f of readdirSync(p).sort()) if (f.endsWith('.md') && f !== 'README.md') files.push(join(p, f));
799
+ } else files.push(p);
800
+ }
801
+ const docs = files.map(f => `## ${f.split('/').pop()}\n\n${readFileSync(f, 'utf8')}`).join('\n\n---\n\n');
802
+ request += `\n\n---\n\n# Standing context - direction documents\n\nThese are the mission, the decisions already taken and the ideas parked for later, as the people running this project keep them. Plan within them. Do not restate them, do not re-decide anything they settle, and do not pull a parked idea into scope unless the request above asks for it. Where the request and a document conflict, the request wins and you say so under "Assumptions".\n\n${docs}`;
803
+ console.log(`context: ${files.length} document(s) appended (${files.map(f => f.split('/').pop()).join(', ')})`);
804
+ }
805
+ const runId = resumeMeta ? basename(resolve(resumeRun)) : new Date().toISOString().replace(/[:.]/g, '-');
806
+ const runDir = join(work, 'runs', runId);
807
+ mkdirSync(runDir, { recursive: true });
808
+ if (!resumeMeta) {
809
+ writeFileSync(join(runDir, 'run.json'), JSON.stringify({ chain: chainNameEff, task: taskPathEff, context: contextArg || null, fromRun: fromRun || null, draft: draftPath || null, rounds: config.maxRounds, maxUsd, taskHash }, null, 2));
810
+ } else if (resumeMeta.rounds) config.maxRounds = resumeMeta.rounds;
811
+
812
+ // v3 §2: refuse to silently resume past a changed task file. A stored taskHash on an older
813
+ // run (pre-v3) is absent, not mismatched - trusted, not treated as stale, the same posture
814
+ // §7.2's cache staleness check already takes for a missing fingerprint. AMENDMENTS.md is the
815
+ // one path past a real mismatch: an entry covering the current hash means the operator
816
+ // declared the change on the record rather than editing the task file quietly.
817
+ if (resumeMeta) {
818
+ const amendmentsPath = join(runDir, 'AMENDMENTS.md');
819
+ const amendmentsText = existsSync(amendmentsPath) ? readFileSync(amendmentsPath, 'utf8') : null;
820
+ const scopeCheck = checkFrozenScope({ storedHash: resumeMeta.taskHash, currentHash: taskHash, amendmentsText });
821
+ if (!scopeCheck.ok) {
822
+ console.error(`\n${scopeCheck.message}\n(${amendmentsPath})`);
823
+ process.exit(1);
824
+ }
825
+ if (scopeCheck.amended) {
826
+ // The new hash becomes this run's baseline going forward.
827
+ writeFileSync(join(runDir, 'run.json'), JSON.stringify({ ...resumeMeta, taskHash }, null, 2));
828
+ console.log(`task hash mismatch covered by a recorded amendment in ${amendmentsPath} - proceeding.`);
829
+ }
830
+ }
831
+ // Stage cache: <label>.md holds the text, <label>.usage.json what it cost.
832
+ // Both are written as each stage completes, so a resume replays them.
833
+ const cacheFingerprint = fingerprintInputs(rawTaskTextForCacheFingerprint, config);
834
+ setCache({
835
+ get: withStalenessCheck(
836
+ label => {
837
+ const t = join(runDir, `${label}.md`);
838
+ if (!existsSync(t)) return null;
839
+ const u = existsSync(join(runDir, `${label}.usage.json`)) ? JSON.parse(readFileSync(join(runDir, `${label}.usage.json`), 'utf8')) : {};
840
+ return { text: readFileSync(t, 'utf8'), ...u };
841
+ },
842
+ cacheFingerprint,
843
+ label => {
844
+ console.log(` CACHE STALENESS WARNING: stage "${label}" was cached against different task/chain-config inputs than are now in force; re-running it.`);
845
+ appendFileSync(join(runDir, 'WARNINGS.md'), `- cache_stale: stage "${label}" invalidated - task text or chain config changed since it was cached\n`);
846
+ },
847
+ ),
848
+ });
849
+ // A resumed run inherits the ceiling it was started under unless this
850
+ // invocation names a different one - otherwise resuming would silently drop
851
+ // the cap the run was created with.
852
+ const maxUsdEff = argv.includes('--max-usd') ? maxUsd
853
+ : (resumeMeta && 'maxUsd' in resumeMeta) ? resumeMeta.maxUsd
854
+ : maxUsd;
855
+ setBudget(maxUsdEff);
856
+
857
+ // A previous sitting may have stopped this run at the ceiling. Clear that
858
+ // marker now that we are past it, so a run that goes on to finish is not
859
+ // still advertising itself as capped.
860
+ for (const f of ['STOPPED-budget.json', 'STOPPED-budget.md']) {
861
+ if (existsSync(join(runDir, f))) rmSync(join(runDir, f));
862
+ }
863
+
864
+ const logPath = join(runDir, 'run.log');
865
+ const log = (...parts) => {
866
+ const line = parts.join(' ');
867
+ console.log(line);
868
+ appendFileSync(logPath, line + '\n');
869
+ };
870
+
871
+ log(`council run ${runId}${resumeMeta ? ' (resumed)' : ''}`);
872
+ log(`chain: ${config.name} (${config.maxRounds} round cap)`);
873
+ log(`cap: ${maxUsdEff === null ? 'none - this run has no spend ceiling' : `${formatUsd(maxUsdEff)} per run (--max-usd)`}`);
874
+ log(`task: ${taskPathEff}`);
875
+
876
+ // v2 plan §6: before any stage runs, before a single metered API call, a static keyword
877
+ // check for the specific class of conflict this project already hit once (a chain requiring
878
+ // an appended section against text demanding a standalone document). Warns, never blocks.
879
+ if (!resumeMeta) {
880
+ // v3 §3: same call site, same warn-never-block posture, checking for a task that names a
881
+ // file it never inlines verbatim rather than a section/criteria conflict.
882
+ const preflightWarnings = [...preflightCheck(config, request), ...checkArtifactReferences(request)];
883
+ if (preflightWarnings.length) {
884
+ for (const w of preflightWarnings) log(` PRE-FLIGHT WARNING: ${w.message}`);
885
+ if (!existsSync(join(runDir, 'WARNINGS.md'))) writeFileSync(join(runDir, 'WARNINGS.md'), '# Warnings\n\n');
886
+ appendFileSync(join(runDir, 'WARNINGS.md'), preflightWarnings.map(w => `- pre_flight: ${w.message}\n`).join(''));
887
+ }
888
+ }
889
+
890
+ log(resumeMeta ? `resume: stages already on disk replay for free` : '');
891
+ let result;
892
+ try {
893
+ result = await runChain({
894
+ request,
895
+ config,
896
+ draft: handedDraft,
897
+ log,
898
+ onStage: s => {
899
+ if (s.cached) return;
900
+ // v2 plan §7.1: validate against the stage contract's required_sections before
901
+ // trusting this deliverable - same class of bug as the lab-dropout fix, just one
902
+ // stage later in the pipeline. Warns loudly and records it; does not, and cannot
903
+ // from here, change chain.js's own in-run control flow (§11: mechanism untouched).
904
+ const kind = stageKindOf(s.label);
905
+ if (kind) {
906
+ const check = validateDeliverable(kind, s.text);
907
+ if (!check.ok) {
908
+ log(` PARTIAL OUTPUT WARNING: stage "${s.label}" (${kind}) - ${check.reason}`);
909
+ appendFileSync(join(runDir, 'WARNINGS.md'), `- partial_output: stage "${s.label}" (${kind}) - ${check.reason}\n`);
910
+ }
911
+ }
912
+ writeFileSync(join(runDir, `${s.label}.md`), s.text);
913
+ writeFileSync(join(runDir, `${s.label}.usage.json`), JSON.stringify({ provider: s.provider, model: s.model, usage: s.usage, usd: s.usd, ms: s.ms, inputsFingerprint: cacheFingerprint }));
914
+ // v5 §1 candidate 14: one JSONL line per stage, alongside the existing markdown/usage
915
+ // artifacts - structured so future tooling (candidate #9's replay, #2's independence
916
+ // report) can read a run without re-parsing prose. No prompt content, same privacy
917
+ // posture as verdict-stats.js: seat/lab/counts/cost/timing only.
918
+ appendFileSync(join(runDir, 'stage-log.jsonl'), `${JSON.stringify({
919
+ stage: s.label, seat: `${s.provider}/${s.model}`, lab: s.lab,
920
+ tokensIn: s.usage?.input ?? 0, tokensOut: s.usage?.output ?? 0,
921
+ usd: s.usd, ms: s.ms, outcome: s.text ? 'ok' : 'empty',
922
+ })}\n`);
923
+ // v2 plan §5: regenerate at every stage-completion boundary, always from
924
+ // disk state, never itself trusted as the source of truth.
925
+ writeFileSync(join(runDir, 'RESUME.md'), generateResumeBrief({ runId, dir: runDir, runMeta: { chain: chainNameEff, task: taskPathEff }, chainConfig: config }));
926
+ },
927
+ });
928
+ } catch (err) {
929
+ if (err instanceof ExternalPause) {
930
+ const need = join(runDir, `NEEDS-${err.label}.md`);
931
+ writeFileSync(need, withIntegrityFooter(`# External stage: ${err.label}\n\nWrite the reply to \`${join(runDir, `${err.label}.md`)}\` and run:\n\n node src/cli.js --resume runs/${runId}\n\n## System prompt\n\n${err.system}\n\n## User prompt\n\n${err.user}`));
932
+ log(`\nPAUSED: stage "${err.label}" is an external seat.`);
933
+ log(` prompt: ${need}`);
934
+ log(` answer: write ${join(runDir, `${err.label}.md`)}`);
935
+ log(` resume: node src/cli.js --resume runs/${runId}`);
936
+ process.exit(3);
937
+ }
938
+ if (err instanceof BudgetExceeded) {
939
+ // Deliberately NO report.json and NO deliverable.md: a run folder that
940
+ // carries those reads as a finished run everywhere else in this codebase.
941
+ // What lands instead says plainly that the run stopped short.
942
+ const state = budgetState();
943
+ writeFileSync(join(runDir, 'STOPPED-budget.json'), JSON.stringify({
944
+ stoppedAt: err.label,
945
+ seat: err.seat,
946
+ spentUsd: err.spent,
947
+ capUsd: err.cap,
948
+ projectedStageUsd: err.projected,
949
+ note: 'Stopped before the stage above was paid for. Completed stages are on disk and replay for free on resume.',
950
+ }, null, 2));
951
+ writeFileSync(join(runDir, 'STOPPED-budget.md'), `# Run stopped: per-run spend cap reached
952
+
953
+ This run stopped **before** stage \`${err.label}\` (${err.seat}) was called, so that stage was
954
+ never paid for.
955
+
956
+ - spent so far: **${formatUsd(err.spent)}**
957
+ - ceiling: **${formatUsd(err.cap)}**
958
+ - that stage could have cost up to: **${formatUsd(err.projected)}**
959
+
960
+ Stage cost is projected as the whole prompt billed as input plus the seat's entire \`maxTokens\`
961
+ budget billed as output, so the real cost would very likely have been lower. The ceiling is
962
+ enforced against the worst case on purpose.
963
+
964
+ ## Continue it
965
+
966
+ Every completed stage is on disk and replays for free, so resuming only pays for what is left:
967
+
968
+ node src/cli.js --resume runs/${runId} --max-usd ${(Math.ceil((err.cap + err.projected) * 100) / 100).toFixed(2)}
969
+
970
+ Or \`--max-usd none\` to continue with no ceiling.
971
+ `);
972
+ log(`\nSTOPPED: per-run spend cap reached before stage "${err.label}".`);
973
+ log(` spent: ${formatUsd(err.spent)} of ${formatUsd(err.cap)} ceiling`);
974
+ log(` stage: ${err.seat} could cost up to ${formatUsd(err.projected)}`);
975
+ log(` detail: ${join(runDir, 'STOPPED-budget.md')}`);
976
+ log(` resume: node src/cli.js --resume runs/${runId} --max-usd <higher>`);
977
+ process.exit(4);
978
+ }
979
+ throw err;
980
+ }
981
+
982
+ writeFileSync(join(runDir, 'deliverable.md'), result.deliverable);
983
+ if (result.proposalPool?.length && result.proposalPool.length > result.proposals.length) {
984
+ writeFileSync(join(runDir, 'proposals-pool.md'), `# Every proposal every lab wrote (${result.proposalPool.length}); "kept" ones went to the builder\n\n` + result.proposalPool.map(p =>
985
+ `## ${p.kept ? 'KEPT' : 'dropped'} - ${p.lab}/${p.model}, attempt ${p.attempt}\n**Title:** ${p.title}\n**Serves:** ${p.serves}\n**What:** ${p.what}\n**Why:** ${p.why}\n**How:** ${p.how}\n**Acceptance test:** ${p.acceptance_test}`).join('\n\n'));
986
+ }
987
+ // §5 (v3 plan): declined objections are a first-class record, never part of the deliverable text
988
+ // itself, so they get their own section wherever the board already lives - the same file that
989
+ // already lists proposals and debate posts (or a standalone one, if no debate happened this run).
990
+ const disputesSection = result.disputes?.length
991
+ ? `\n\n## Disputed objections (declined by the reviser, kept out of the deliverable)\n\n${result.disputes.map(d => `- Round ${d.round}: ${d.reason}`).join('\n')}`
992
+ : '';
993
+ if (result.board || disputesSection) writeFileSync(join(runDir, 'BOARD.md'), `# Debate board - run ${runId}\n\n${result.board ? `Every proposal, what the other labs posted on it, and the author's reply.\n\n${result.board}` : 'No proposal debate ran this round.'}${disputesSection}`);
994
+ if (result.handoff) writeFileSync(join(runDir, 'HANDOFF.md'), result.handoff);
995
+ if (result.proposals?.length) {
996
+ writeFileSync(join(runDir, 'proposals.md'), result.proposals.map(p =>
997
+ `## ${p.id} (${p.lab}/${p.model})\n**Title:** ${p.title}\n**Serves:** ${p.serves}\n**What:** ${p.what}\n**Why:** ${p.why}\n**How:** ${p.how}\n**Acceptance test:** ${p.acceptance_test}`).join('\n\n'));
998
+ }
999
+ writeFileSync(join(runDir, 'report.json'), JSON.stringify(reportJsonShape({
1000
+ runId, chain: config.name, task: taskPathEff, result, config,
1001
+ fromRun: fromRun || resumeMeta?.fromRun || null, maxUsd: maxUsdEff,
1002
+ }), null, 2));
1003
+ // Final regeneration: the last onStage-triggered RESUME.md was written before
1004
+ // deliverable.md/report.json existed, so without this it would keep reporting
1005
+ // "in progress" forever on an already-finished run. Run after report.json so
1006
+ // an orphaned-withdrawal warning (v5 §1 candidate 3) can be read back from it.
1007
+ writeFileSync(join(runDir, 'RESUME.md'), generateResumeBrief({ runId, dir: runDir, runMeta: { chain: chainNameEff, task: taskPathEff }, chainConfig: config }));
1008
+
1009
+ const t = result.totals;
1010
+ log(`\n---`);
1011
+ if (result.signoff) {
1012
+ log(`panel: ${result.signoff.map(s => `${s.provider}${s.signedOff === null ? ' ?' : s.signedOff ? ' ✓' : ' ✗'}`).join(' ')}`);
1013
+ if (result.signoff.some(s => s.signedOff === null)) log(` ? = no usable reply (truncated, malformed, or the lab was down), abstained - neither a pass nor an objection`);
1014
+ }
1015
+ log(`verdict: ${result.passed
1016
+ ? (result.signoff ? 'every lab on the panel signed off' : 'a critic from another lab passed it')
1017
+ : 'stopped with open failures (see report.json)'}`);
1018
+ if (!result.passed && result.lastCritique?.failures?.length) {
1019
+ log(`open: ${result.lastCritique.failures.length} objection(s) for human review - the labs can be wrong, read the draft before acting on these`);
1020
+ for (const f of result.lastCritique.failures) log(` - ${f.lab ? `${f.lab}: ` : ''}${f.problem || f.criterion}`);
1021
+ }
1022
+ if (result.scoreboard) {
1023
+ log(`labs: ${result.scoreboard.labs.map(l => `${l.lab} ${l.accepted}/${l.proposed}`).join(' ')} (accepted/proposed; "built" is yours to fill after the build session)`);
1024
+ }
1025
+ log(`tokens: ${t.input} in, ${t.output} out, ${t.total} total`);
1026
+ log(`cost: ${formatUsd(t.usd)}${t.unpriced.length ? ` (+ unpriced: ${t.unpriced.join(', ')})` : ''}${maxUsdEff === null ? '' : ` of ${formatUsd(maxUsdEff)} ceiling`}`);
1027
+ const boardWritten = result.board || disputesSection;
1028
+ log(`output: ${join(runDir, 'deliverable.md')}${result.handoff ? ` (+ HANDOFF.md${boardWritten ? ', BOARD.md' : ''})` : boardWritten ? ' (+ BOARD.md)' : ''}`);
1029
+
1030
+ }
1031
+ // end of the non-MCP path (see the --mcp branch at the top of this file)