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.
- package/.env.example +19 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/PERSONAS.md +51 -0
- package/README.md +499 -0
- package/TROUBLESHOOTING.md +45 -0
- package/chains/cheap.json +35 -0
- package/chains/gp-judge-v1.json +80 -0
- package/chains/idea-open-c2.json +106 -0
- package/chains/mock-budget.json +15 -0
- package/chains/mock-debate.json +59 -0
- package/chains/mock-external.json +59 -0
- package/chains/mock-open.json +60 -0
- package/chains/mock-proposals.json +57 -0
- package/chains/mock-questions-wait.json +63 -0
- package/chains/mock-questions.json +63 -0
- package/chains/mock-relay.json +42 -0
- package/chains/mock-unanimous.json +42 -0
- package/chains/mock.json +17 -0
- package/chains/plan-auto-mistral.json +101 -0
- package/chains/plan-auto.json +131 -0
- package/chains/plan-cheap.json +82 -0
- package/chains/plan-debate-c2.json +86 -0
- package/chains/plan-debate-open-c2.json +98 -0
- package/chains/plan-debate-roles-c1.json +149 -0
- package/chains/plan-debate.json +116 -0
- package/chains/plan-fast.json +66 -0
- package/chains/plan-proposals-best-of-5.json +98 -0
- package/chains/plan-proposals.json +96 -0
- package/chains/plan-relay.json +83 -0
- package/chains/plan-thorough.json +82 -0
- package/chains/plan-unanimous.json +82 -0
- package/chains/seven-cheap.json +67 -0
- package/chains/seven.json +60 -0
- package/chains/smo-v2-cheap.json +67 -0
- package/chains/smo.json +50 -0
- package/chains/verify.json +40 -0
- package/package.json +55 -0
- package/src/board-export.js +77 -0
- package/src/budget.js +126 -0
- package/src/buys.js +44 -0
- package/src/cache-integrity.js +38 -0
- package/src/chain-lint.js +107 -0
- package/src/chain.js +880 -0
- package/src/cli.js +1031 -0
- package/src/cost-forecast.js +70 -0
- package/src/cost.js +139 -0
- package/src/errors.js +82 -0
- package/src/http-server.js +349 -0
- package/src/integrity.js +48 -0
- package/src/key-redaction.js +97 -0
- package/src/mcp/server.js +319 -0
- package/src/name-lint.js +186 -0
- package/src/partial-deliverable.js +38 -0
- package/src/peer-claim.js +98 -0
- package/src/personas.js +54 -0
- package/src/preflight.js +84 -0
- package/src/pricing.json +120 -0
- package/src/providers.js +268 -0
- package/src/replay.js +40 -0
- package/src/resume-brief.js +114 -0
- package/src/role-diagnostics.js +181 -0
- package/src/roles.js +520 -0
- package/src/schema-version.js +39 -0
- package/src/scope-freeze.js +35 -0
- package/src/seat-role.js +96 -0
- package/src/shape-rounds.js +58 -0
- package/src/spend.js +193 -0
- package/src/stage-contract.js +223 -0
- package/src/stage-submission.js +53 -0
- package/src/tie-break.js +94 -0
- package/src/ui/app.js +139 -0
- package/src/ui/index.html +43 -0
- package/src/ui/parse.js +101 -0
- package/src/ui/server.js +117 -0
- package/src/ui/style.css +37 -0
- package/src/verdict-stats.js +267 -0
- 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)
|