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
@@ -0,0 +1,48 @@
1
+ // Prompt-file truncation integrity (v2 plan §10 Phase 2 item 4, DEEPSEEK-2 / GLM-2).
2
+ //
3
+ // Real incident: a 61,000-token NEEDS-<stage>.md was cut off when a driving session
4
+ // tried to read its own prompt back. The session could not see what it was actually
5
+ // being asked. That is a read/write correctness bug, independent of size or cost -
6
+ // this module closes it with a footer marker any generated prompt file carries, and
7
+ // a read-side check that raises a loud warning rather than silently proceeding when
8
+ // the marker is missing or doesn't match the actual content.
9
+ import { createHash } from 'node:crypto';
10
+
11
+ const FOOTER_RE = /\n<!-- INTEGRITY: lines=(\d+) bytes=(\d+) sha256=([0-9a-f]{12}) -->\n?$/;
12
+
13
+ export function fingerprint(content) {
14
+ const lines = content.split('\n').length;
15
+ const bytes = Buffer.byteLength(content, 'utf8');
16
+ const sha256 = createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 12);
17
+ return { lines, bytes, sha256 };
18
+ }
19
+
20
+ /** Append an integrity footer to `content`, computed over `content` itself (before the footer). */
21
+ export function withIntegrityFooter(content) {
22
+ const { lines, bytes, sha256 } = fingerprint(content);
23
+ return `${content}\n<!-- INTEGRITY: lines=${lines} bytes=${bytes} sha256=${sha256} -->\n`;
24
+ }
25
+
26
+ /**
27
+ * Verify a file written by withIntegrityFooter(). Returns { ok: true } when the footer
28
+ * is present and matches the body it's attached to, or { ok: false, warning } naming
29
+ * exactly what's wrong (missing footer vs. a mismatched count/hash - the second is the
30
+ * truncation/corruption signature, the first means this file predates the fix or was
31
+ * never covered by it).
32
+ */
33
+ export function verifyIntegrityFooter(fileContent) {
34
+ const match = fileContent.match(FOOTER_RE);
35
+ if (!match) {
36
+ return { ok: false, warning: 'no integrity footer found - this file was not written with an integrity marker, or the marker itself was truncated away' };
37
+ }
38
+ const [footerLine, expectedLines, expectedBytes, expectedSha] = match;
39
+ const body = fileContent.slice(0, fileContent.length - footerLine.length);
40
+ const actual = fingerprint(body);
41
+ if (String(actual.lines) !== expectedLines || String(actual.bytes) !== expectedBytes || actual.sha256 !== expectedSha) {
42
+ return {
43
+ ok: false,
44
+ warning: `integrity footer does not match this file's actual content - it looks truncated or corrupted (expected lines=${expectedLines} bytes=${expectedBytes} sha256=${expectedSha}, got lines=${actual.lines} bytes=${actual.bytes} sha256=${actual.sha256})`,
45
+ };
46
+ }
47
+ return { ok: true };
48
+ }
@@ -0,0 +1,97 @@
1
+ // v5 §1 candidate 11: scan task files and chain configs for text shaped like a real API key
2
+ // before it is ever committed or shared. BYOK means the user's own keys are the entire trust
3
+ // boundary - nothing currently stops one from ending up pasted into a task file by accident, and
4
+ // this repo is public.
5
+ //
6
+ // Read-only, offline, no network call. The match location is reported; the matched text itself
7
+ // never is - printing the very thing you're trying to keep out of a shared log defeats the scan.
8
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
9
+ import { join, extname } from 'node:path';
10
+
11
+ // Each pattern names the provider shape it targets and requires enough length/character variety
12
+ // to make a legitimate hex-looking string (a run ID, a git SHA, a content hash) an unlikely
13
+ // match. Deliberately conservative: a missed key is a false negative the user still owns; a
14
+ // false positive on every SHA in every commit message would make the scan noise the maintainer
15
+ // (and every stranger who runs it) learns to ignore.
16
+ const KEY_PATTERNS = [
17
+ { name: 'OpenAI-style (sk-...)', re: /\bsk-[A-Za-z0-9]{20,}\b/g },
18
+ { name: 'OpenAI project-scoped (sk-proj-...)', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g },
19
+ { name: 'Anthropic (sk-ant-...)', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
20
+ { name: 'Google AI (AIza...)', re: /\bAIza[A-Za-z0-9_-]{30,}\b/g },
21
+ { name: 'xAI (xai-...)', re: /\bxai-[A-Za-z0-9]{20,}\b/g },
22
+ { name: 'Groq (gsk_...)', re: /\bgsk_[A-Za-z0-9]{20,}\b/g },
23
+ { name: 'Mistral (32 hex chars, mistral context only)', re: /\b[a-f0-9]{32}\b/g, contextRe: /mistral/i },
24
+ { name: 'generic Bearer-token assignment', re: /\b(?:api[_-]?key|authorization|bearer)\s*[:=]\s*["']?[A-Za-z0-9_-]{24,}["']?/gi },
25
+ ];
26
+
27
+ // Run IDs (`2026-09-13T18-01-29-810Z`), git SHAs standing alone, and UUIDs are the legitimate
28
+ // hex-looking content this project's own files are full of - excluding them by shape, not by
29
+ // location, keeps the scan honest across any file it's pointed at.
30
+ const KNOWN_SAFE = [
31
+ /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/, // run folder timestamp
32
+ /^[0-9a-f]{7,8}$/, // a short git SHA
33
+ /^[0-9a-f]{40}$/, // a full git SHA (never 32 - that's an MD5-shaped length this scan deliberately still treats as key-shaped in provider context)
34
+ ];
35
+
36
+ function isKnownSafe(match) {
37
+ return KNOWN_SAFE.some(re => re.test(match));
38
+ }
39
+
40
+ // Scans one file's text, returning `{ line, column, pattern }` for each match - never the
41
+ // matched substring itself.
42
+ export function scanText(text, filename = '') {
43
+ const hits = [];
44
+ const lines = text.split('\n');
45
+ for (const pattern of KEY_PATTERNS) {
46
+ if (pattern.contextRe && !pattern.contextRe.test(filename) && !pattern.contextRe.test(text)) continue;
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const line = lines[i];
49
+ pattern.re.lastIndex = 0;
50
+ let m;
51
+ while ((m = pattern.re.exec(line))) {
52
+ if (isKnownSafe(m[0])) continue;
53
+ hits.push({ line: i + 1, column: m.index + 1, pattern: pattern.name });
54
+ }
55
+ }
56
+ }
57
+ return hits;
58
+ }
59
+
60
+ const SCANNABLE_EXT = new Set(['.md', '.json', '.txt', '.yaml', '.yml', '.js']);
61
+
62
+ function listFiles(root) {
63
+ const out = [];
64
+ const walk = dir => {
65
+ let entries;
66
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
67
+ for (const e of entries) {
68
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'runs') continue;
69
+ const p = join(dir, e.name);
70
+ if (e.isDirectory()) walk(p);
71
+ else if (SCANNABLE_EXT.has(extname(e.name))) out.push(p);
72
+ }
73
+ };
74
+ const st = statSync(root, { throwIfNoEntry: false });
75
+ if (!st) return out;
76
+ if (st.isDirectory()) walk(root); else out.push(root);
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Scan every task file and chain config under `roots` (task files, `chains/`) for key-shaped
82
+ * text. Returns `{ findings: [{ file, line, column, pattern }], filesScanned }`. Never reads or
83
+ * returns the matched substring - only its location and which pattern it matched.
84
+ */
85
+ export function scanArtifacts(roots) {
86
+ const findings = [];
87
+ let filesScanned = 0;
88
+ for (const root of roots) {
89
+ for (const file of listFiles(root)) {
90
+ filesScanned++;
91
+ let text;
92
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
93
+ for (const hit of scanText(text, file)) findings.push({ file, ...hit });
94
+ }
95
+ }
96
+ return { findings, filesScanned };
97
+ }
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ // The High Council as an MCP server: Claude Code (or any MCP client) as command and
3
+ // control of the harness. Stdio transport, no network. Tools mirror the CLI:
4
+ // start a run, watch it, read what it produced, grade a draft panel-only,
5
+ // price a chain. Runs are spawned detached so a long run outlives the tool
6
+ // call; the client polls with run_status.
7
+ //
8
+ // claude mcp add high-council -- npx -y the-high-council
9
+ // from a clone: claude mcp add high-council -- node /absolute/path/to/src/mcp/server.js
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+ import { spawn, execFileSync } from 'node:child_process';
14
+ import { readFileSync, readdirSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { join, dirname, resolve, basename } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { parseSections, flatten, parseLedger, words } from '../ui/parse.js';
18
+ import { spendReport, costToday } from '../spend.js';
19
+ import { stageKindOf, buildStageContract, renderStagePromptBundle } from '../stage-contract.js';
20
+ import { verifyIntegrityFooter } from '../integrity.js';
21
+ import { generateResumeBrief } from '../resume-brief.js';
22
+ import { verdictStats } from '../verdict-stats.js';
23
+ import { checkClaimStaleness } from '../peer-claim.js';
24
+ import { submitStageAnswer } from '../stage-submission.js';
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ // Same split as the CLI: `pkg` ships with the package (chains/, the CLI
28
+ // itself), `work` belongs to the user. An MCP client launches this server with
29
+ // the user's project as cwd, so task files and run output land where they can
30
+ // find them rather than inside node_modules. In a clone the two are identical.
31
+ const pkg = resolve(here, '../..');
32
+ const work = process.cwd();
33
+ const runsDir = join(work, 'runs');
34
+ const cli = join(pkg, 'src', 'cli.js');
35
+
36
+ const text = s => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] });
37
+ const safeRun = id => /^[0-9TZ-]+$/.test(id) && existsSync(join(runsDir, id));
38
+ const readJson = p => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
39
+
40
+ // What a run has spent and what it has left. Source of truth is report.json
41
+ // once the run finished; while it is still running (or was stopped short)
42
+ // the per-stage <label>.usage.json files are the only record, so they are
43
+ // summed directly.
44
+ function budgetOf(dir, report) {
45
+ const stopped = readJson(join(dir, 'STOPPED-budget.json'));
46
+ let spent = report?.totals?.usd;
47
+ if (spent === undefined || spent === null) {
48
+ spent = !existsSync(dir) ? 0 : readdirSync(dir)
49
+ .filter(f => f.endsWith('.usage.json'))
50
+ .reduce((sum, f) => sum + (readJson(join(dir, f))?.usd ?? 0), 0);
51
+ }
52
+ const cap = report?.maxUsd ?? stopped?.capUsd ?? readJson(join(dir, 'run.json'))?.maxUsd ?? null;
53
+ return {
54
+ spentUsd: spent,
55
+ capUsd: cap,
56
+ remainingUsd: cap === null || cap === undefined ? null : Math.max(0, cap - spent),
57
+ stoppedByCap: stopped ? { stage: stopped.stoppedAt, seat: stopped.seat, projectedStageUsd: stopped.projectedStageUsd } : null,
58
+ };
59
+ }
60
+
61
+ function runSummary(id) {
62
+ const dir = join(runsDir, id);
63
+ const report = readJson(join(dir, 'report.json'));
64
+ const log = existsSync(join(dir, 'run.log')) ? readFileSync(join(dir, 'run.log'), 'utf8') : '';
65
+ const last = log.trim().split('\n').slice(-3).join(' | ');
66
+ const alive = isAlive(id);
67
+ const budget = budgetOf(dir, report);
68
+ return {
69
+ id,
70
+ chain: report?.chain || (log.match(/^chain: (\S+)/m) || [])[1] || null,
71
+ task: report?.task || (log.match(/^task: +(\S+)/m) || [])[1] || null,
72
+ state: report ? (report.passed ? 'done: every lab signed off' : 'done: open objections')
73
+ : budget.stoppedByCap ? `stopped: per-run spend cap reached before stage ${budget.stoppedByCap.stage} - resume with a higher --max-usd`
74
+ : waiting(dir) ? `paused: waiting for external stage ${waiting(dir)}`
75
+ : alive ? 'running'
76
+ : 'stopped without a report (crashed, killed, or paused and answered but not resumed)',
77
+ usd: report?.totals?.usd ?? null,
78
+ budget,
79
+ signoff: report?.signoff ?? null,
80
+ scoreboard: report?.scoreboard?.labs ?? null,
81
+ waitingFor: existsSync(dir) ? readdirSync(dir).filter(f => f.startsWith('NEEDS-')).map(f => f.slice(6, -3)).filter(l => !existsSync(join(dir, `${l}.md`))) : [],
82
+ files: existsSync(dir) ? readdirSync(dir).filter(f => !f.endsWith('.usage.json')).sort() : [],
83
+ lastLogLines: last,
84
+ };
85
+ }
86
+
87
+ function waiting(dir) {
88
+ if (!existsSync(dir)) return null;
89
+ const w = readdirSync(dir).filter(f => f.startsWith('NEEDS-')).map(f => f.slice(6, -3)).filter(l => !existsSync(join(dir, `${l}.md`)));
90
+ return w[0] || null;
91
+ }
92
+
93
+ function isAlive(id) {
94
+ try {
95
+ const out = execFileSync('pgrep', ['-af', 'src/cli.js'], { encoding: 'utf8' });
96
+ // The run id is not on the command line; match by the newest cli process
97
+ // whose run.log is this run's. Cheap approximation: any cli.js alive and
98
+ // this run has no report yet.
99
+ return out.trim().length > 0 && !existsSync(join(runsDir, id, 'report.json'));
100
+ } catch { return false; }
101
+ }
102
+
103
+ const server = new McpServer({ name: 'the-high-council', version: '0.3.0' });
104
+
105
+ server.tool('list_chains', 'Chains available to run, with their description and worst-case price from a dry run.', {}, async () => {
106
+ const chains = readdirSync(join(pkg, 'chains')).filter(f => f.endsWith('.json')).map(f => {
107
+ const c = readJson(join(pkg, 'chains', f));
108
+ return { name: c.name, description: c.description, maxRounds: c.maxRounds, signoff: c.signoff || 'first', proposals: !!c.proposals, debate: !!c.debate, handoff: !!c.handoff };
109
+ });
110
+ return text(chains);
111
+ });
112
+
113
+ server.tool('dry_run', 'Price a chain without calling any model.', { chain: z.string() }, async ({ chain }) => {
114
+ const out = execFileSync('node', [cli, '--chain', chain, '--dry-run'], { encoding: 'utf8', cwd: work });
115
+ return text(out);
116
+ });
117
+
118
+ server.tool('start_run', 'Start a harness run in the background. Returns the run id to poll with run_status. task is a path relative to your working directory (tasks/x.md) or absolute; context is optional (context/war-of-love). draft + from_run + rounds=1 makes a panel-only grading pass.', {
119
+ chain: z.string(),
120
+ task: z.string(),
121
+ context: z.string().optional(),
122
+ draft: z.string().optional().describe('path to a draft to review instead of building one'),
123
+ from_run: z.string().optional().describe('reuse this earlier run\'s criteria'),
124
+ rounds: z.number().int().min(1).max(5).optional(),
125
+ max_usd: z.number().min(0).optional().describe('per-run spend ceiling in USD. Defaults to MAX_USD_PER_RUN or $5. Pass 0 for no ceiling. The run stops cleanly before any stage that could breach it, and resumes with a higher ceiling.'),
126
+ }, async ({ chain, task, context, draft, from_run, rounds, max_usd }) => {
127
+ const args = [cli, '--chain', chain, '--task', resolve(work, task)];
128
+ if (context) args.push('--context', resolve(work, context));
129
+ if (draft) args.push('--draft', resolve(work, draft));
130
+ if (from_run) args.push('--from-run', resolve(work, from_run));
131
+ if (rounds) args.push('--rounds', String(rounds));
132
+ if (max_usd !== undefined) args.push('--max-usd', max_usd === 0 ? 'none' : String(max_usd));
133
+ mkdirSync(runsDir, { recursive: true });
134
+ const before = new Set(readdirSync(runsDir));
135
+ const logPath = join(work, `council-${Date.now()}.log`);
136
+ const fd = (await import('node:fs')).openSync(logPath, 'a');
137
+ const child = spawn('node', args.slice(0), { cwd: work, detached: true, stdio: ['ignore', fd, fd] });
138
+ child.unref();
139
+ // The run creates its folder within a second or two; find it.
140
+ let id = null;
141
+ for (let i = 0; i < 20 && !id; i++) {
142
+ await new Promise(r => setTimeout(r, 250));
143
+ id = readdirSync(runsDir).find(d => !before.has(d)) || null;
144
+ }
145
+ return text({ started: true, pid: child.pid, run: id, log: logPath, note: id ? 'poll run_status(run)' : 'run folder not seen yet; call list_runs shortly' });
146
+ });
147
+
148
+ server.tool('external_prompt', 'When a run is paused at an external seat: the exact system and user prompt that stage needs answered. Answer with submit_stage.', { run: z.string() }, async ({ run }) => {
149
+ if (!safeRun(run)) return text({ error: 'no such run' });
150
+ const dir = join(runsDir, run);
151
+ const label = waiting(dir);
152
+ if (!label) return text({ error: 'this run is not waiting for an external stage', state: runSummary(run).state });
153
+ const prompt = readFileSync(join(dir, `NEEDS-${label}.md`), 'utf8');
154
+ // v2 plan §10 Phase 2 item 4: a large prompt file silently truncated by a read is the
155
+ // exact failure this project already suffered once - a session that could not see its
156
+ // own prompt. Never hand that content back looking fine when it isn't.
157
+ const check = verifyIntegrityFooter(prompt);
158
+ // v3 plan §1: a claim's staleness/contest status alongside the prompt this tool already
159
+ // returns - no new tool call needed to see whether this stage is stalled or contested.
160
+ const claimWarning = checkClaimStaleness(dir, label);
161
+ return text({ run, stage: label, prompt, answerWith: `submit_stage(run, "${label}", <text>)`, ...(check.ok ? {} : { integrity_warning: check.warning }), claim: claimWarning || { type: 'none' } });
162
+ });
163
+
164
+ // v2 plan §3 (~/Projects/relay/runs/2026-09-11T12-19-34-184Z/deliverable.md). A driving
165
+ // session with other work sharing its context can hand this one file's path to a fresh
166
+ // subagent instead of authoring the external stage inline itself - see docs/dispatch-pattern.md.
167
+ // Deliberately does NOT inline the stage's own system/user prompt (that is what NEEDS-<stage>.md
168
+ // already holds, and can be tens of thousands of tokens - the arbitrage this project protects,
169
+ // not a bug); it is referenced by path so the bundle itself stays small enough for a fresh
170
+ // subagent's own context to hold comfortably, and reads it in full only if it chooses to delegate.
171
+ server.tool('prepare_stage_prompt', 'For a run paused at an external seat: write stage_prompt.md, a self-contained bundle a fresh subagent can act on with zero prior context, so a driving session can dispatch the stage instead of authoring it inline. Fork a subagent, give it only this file\'s path; take its returned deliverable back to submit_stage.', { run: z.string() }, async ({ run }) => {
172
+ if (!safeRun(run)) return text({ error: 'no such run' });
173
+ const dir = join(runsDir, run);
174
+ const label = waiting(dir);
175
+ if (!label) return text({ error: 'this run is not waiting for an external stage', state: runSummary(run).state });
176
+ const runMeta = readJson(join(dir, 'run.json'));
177
+ const chainConfig = runMeta ? readJson(join(pkg, 'chains', `${runMeta.chain}.json`)) : null;
178
+ const kind = stageKindOf(label);
179
+ if (!chainConfig || !kind) return text({ error: 'could not resolve this stage to a known stage kind - the chain config or stage label is not one prepare_stage_prompt recognises', label });
180
+ const contract = buildStageContract(chainConfig, kind);
181
+ const taskText = runMeta?.task && existsSync(runMeta.task) ? readFileSync(runMeta.task, 'utf8') : '(task file not found on disk - see run.json for its original path)';
182
+ const needsPath = join(dir, `NEEDS-${label}.md`);
183
+ const boardPath = join(dir, 'BOARD.md');
184
+ const references = [needsPath, ...(existsSync(boardPath) ? [boardPath] : []), ...(runMeta?.context ? [runMeta.context] : [])];
185
+ const bundle = renderStagePromptBundle({ contract, taskText, chainName: runMeta?.chain, label, run, references });
186
+ writeFileSync(join(dir, 'stage_prompt.md'), bundle);
187
+ return text({ written: join(dir, 'stage_prompt.md'), stage: label, dispatch: 'Fork a subagent and give it only this file\'s path - not its contents inline.' });
188
+ });
189
+
190
+ server.tool('submit_stage', 'Write the answer for an external stage into the run folder, then resume the run in the background. The stage must be the one external_prompt reported. claimed_by is optional (v3 §1): a self-declared peer-session name, recorded as this stage\'s claim; every check here is warn-only and never blocks the write, so a caller that omits it sees exactly today\'s behavior.', { run: z.string(), stage: z.string().regex(/^[a-z0-9-]+$/), content: z.string(), claimed_by: z.string().optional().describe('self-declared peer-session name, recorded as this stage\'s claim before the answer is written') }, async ({ run, stage, content, claimed_by }) => {
191
+ if (!safeRun(run)) return text({ error: 'no such run' });
192
+ const dir = join(runsDir, run);
193
+ // A stage this run never paused for is still rejected outright - that is not one of §1's
194
+ // three warn-only checks, it is the pre-existing safety check this tool already had. What
195
+ // changes for §1 is narrower: a stage that WAS an external pause point, and already has an
196
+ // answer on disk (a late/duplicate submission), is no longer rejected here - it falls through
197
+ // to the duplicate_answer warning below instead, so neither answer is silently lost.
198
+ const wasExternalStage = existsSync(join(dir, `NEEDS-${stage}.md`));
199
+ const alreadyAnswered = existsSync(join(dir, `${stage}.md`));
200
+ if (!wasExternalStage || (!alreadyAnswered && waiting(dir) !== stage)) {
201
+ return text({ error: `run is not waiting for "${stage}"`, waitingFor: waiting(dir) });
202
+ }
203
+
204
+ // v3 plan §1: three warn-only checks, all evaluated before the answer file is accepted as
205
+ // final - none of them reject the write, none change submit_stage's existing required
206
+ // arguments or return shape for a caller that omits claimed_by. Factored into
207
+ // src/stage-submission.js so it's testable without the MCP transport (test/peer-claim.test.js).
208
+ const runMeta = readJson(join(dir, 'run.json'));
209
+ const chainConfig = runMeta ? readJson(join(pkg, 'chains', `${runMeta.chain}.json`)) : null;
210
+ const { warnings, writtenFile, isDuplicate } = submitStageAnswer(dir, stage, content, { claimedBy: claimed_by, chainConfig });
211
+
212
+ if (isDuplicate) {
213
+ return text({ written: `${stage}.late.md`, words: words(content), warnings, note: `"${stage}.md" already existed and was left untouched; this submission was kept as "${stage}.late.md" for a human or driving session to resolve.` });
214
+ }
215
+ return text({ written: `${stage}.md`, words: words(content), ...(warnings.length ? { warnings } : {}), ...(await resume(run)) });
216
+ });
217
+
218
+ server.tool('resume_run', 'Resume a paused run after its external stage was answered (submit_stage does this for you), or a run stopped by the spend cap (pass a higher max_usd). Completed stages replay from disk and cost nothing.', { run: z.string(), max_usd: z.number().min(0).optional().describe('raise the per-run ceiling for the rest of this run. 0 removes it.') }, async ({ run, max_usd }) => {
219
+ if (!safeRun(run)) return text({ error: 'no such run' });
220
+ return text(await resume(run, max_usd));
221
+ });
222
+
223
+ async function resume(run, maxUsd) {
224
+ const logPath = join(work, `council-${Date.now()}.log`);
225
+ const fs = await import('node:fs');
226
+ const fd = fs.openSync(logPath, 'a');
227
+ const resumeArgs = [cli, '--resume', join('runs', run)];
228
+ if (maxUsd !== undefined) resumeArgs.push('--max-usd', maxUsd === 0 ? 'none' : String(maxUsd));
229
+ const child = spawn('node', resumeArgs, { cwd: work, detached: true, stdio: ['ignore', fd, fd] });
230
+ child.unref();
231
+ return { resumed: true, run, pid: child.pid, log: logPath, note: 'poll run_status(run); it may pause again at the next external stage' };
232
+ }
233
+
234
+ server.tool('spend_report', 'What every run has cost across a window of days, not just one run. Derived from the run folders on disk - nothing is recorded anywhere else and nothing leaves this machine. Use this to answer "what have I spent today" before starting another run.', {
235
+ days: z.number().min(0.1).max(365).optional().describe('how far back to look, in days. Defaults to 1.'),
236
+ }, async ({ days = 1 }) => {
237
+ const r = spendReport(runsDir, { days });
238
+ // v2 plan §8, narrowed per DECISIONS.md: session_cost_today, by local calendar day with
239
+ // a per-model breakdown, additive on this existing tool rather than a new one or a ledger.
240
+ const today = costToday(runsDir);
241
+ return text({
242
+ since: r.since.toISOString(),
243
+ days,
244
+ totalUsd: r.totalUsd,
245
+ runs: r.runs.map(x => ({ id: x.id, chain: x.chain, usd: x.usd, state: x.state })),
246
+ count: r.count,
247
+ ...(r.unreadable ? { unreadableRunFolders: r.unreadable } : {}),
248
+ ...(r.note ? { note: r.note } : {}),
249
+ session_cost_today: { totalUsd: today.totalUsd, count: today.count, perModel: today.perModel },
250
+ source: 'derived from runs/ on disk; no ledger is kept and nothing is transmitted',
251
+ });
252
+ });
253
+
254
+ server.tool('verdict_stats', 'How the debate mechanism itself is doing, per chain and per lab, across a window of days: sign-off rate, mean rounds to sign-off, objections raised, withdrawals vs accepted proposals, dropouts, unparseable replies, shape-only critique rounds (rounds spent entirely on document shape rather than substance), per-lab independence skew (novel-objection rate, solo-signoff rate, a low-independence flag), mean cost and wall time per run, and the largest prompt file per stage type. Derived from report.json and *.usage.json on disk - nothing is recorded anywhere else and nothing leaves this machine. Descriptive only: never reweights a panel or changes a verdict.', {
255
+ days: z.number().min(0.1).max(3650).optional().describe('how far back to look, in days. Defaults to 30.'),
256
+ }, async ({ days = 30 }) => {
257
+ const r = verdictStats(runsDir, { days });
258
+ return text({
259
+ since: r.since.toISOString(),
260
+ days,
261
+ runsSeen: r.runsSeen,
262
+ chains: r.chains,
263
+ labs: r.labs,
264
+ largestPrompts: r.largestPrompts,
265
+ ...(r.unreadable ? { unreadableRunFolders: r.unreadable } : {}),
266
+ ...(r.note ? { note: r.note } : {}),
267
+ source: 'derived from runs/ on disk; no ledger is kept and nothing is transmitted',
268
+ });
269
+ });
270
+
271
+ server.tool('list_runs', 'Runs on disk, newest first, with state and cost.', { limit: z.number().int().min(1).max(100).optional() }, async ({ limit = 15 }) => {
272
+ if (!existsSync(runsDir)) return text([]);
273
+ const ids = readdirSync(runsDir).filter(d => statSync(join(runsDir, d)).isDirectory()).sort().reverse().slice(0, limit);
274
+ return text(ids.map(runSummary));
275
+ });
276
+
277
+ server.tool('run_status', 'State of one run: stage reached, panel verdicts, scoreboard, files produced, cost. Pass brief=true for a short, regenerated-on-demand resume brief instead - what a returning session with fresh context needs to re-enter the run.', { run: z.string(), brief: z.boolean().optional() }, async ({ run, brief }) => {
278
+ if (!safeRun(run)) return text({ error: 'no such run' });
279
+ if (brief) {
280
+ // v2 plan §5: always regenerated from run.json + files on disk, never itself
281
+ // authoritative, so RESUME.md can never become a stale source of truth.
282
+ const dir = join(runsDir, run);
283
+ const runMeta = readJson(join(dir, 'run.json'));
284
+ const chainConfig = runMeta ? readJson(join(pkg, 'chains', `${runMeta.chain}.json`)) : null;
285
+ const rb = generateResumeBrief({ runId: run, dir, runMeta, chainConfig });
286
+ writeFileSync(join(dir, 'RESUME.md'), rb);
287
+ return text(rb);
288
+ }
289
+ const s = runSummary(run);
290
+ const log = readFileSync(join(runsDir, run, 'run.log'), 'utf8');
291
+ s.keyLines = log.split('\n').filter(l => /^(Stage:|Round| [a-z0-9-]+\/.*: (SIGNED OFF|\d+ failure)| FAILED:| board:| .*post\(s\)|panel:|verdict:|labs:|cost:|Error)/.test(l)).slice(-40);
292
+ return text(s);
293
+ });
294
+
295
+ server.tool('read_run_file', 'Read a file from a run folder (deliverable.md, BOARD.md, HANDOFF.md, proposals.md, build.md, revise-1.md, panel-1-<lab>.md, run.log, report.json).', { run: z.string(), file: z.string() }, async ({ run, file }) => {
296
+ if (!safeRun(run) || !/^[A-Za-z0-9._-]+$/.test(file)) return text({ error: 'no such run or bad file name' });
297
+ const p = join(runsDir, run, file);
298
+ if (!existsSync(p)) return text({ error: 'no such file', files: readdirSync(join(runsDir, run)) });
299
+ return text(readFileSync(p, 'utf8'));
300
+ });
301
+
302
+ server.tool('plan_outline', 'Section tree of a run\'s deliverable (or any markdown file) with word counts and the build-volume heuristic, plus the scope ledger if present.', { run: z.string().optional(), file: z.string().optional().describe('absolute path to a markdown file, instead of a run') }, async ({ run, file }) => {
303
+ let md;
304
+ if (file) md = readFileSync(resolve(file), 'utf8');
305
+ else if (run && safeRun(run)) md = readFileSync(join(runsDir, run, 'deliverable.md'), 'utf8');
306
+ else return text({ error: 'give run or file' });
307
+ const tree = parseSections(md);
308
+ const rows = flatten(tree).map(s => ({ path: s.path, words: s.words, volume: s.volume.score, files: s.volume.files.length, scripts: s.volume.scripts.length }));
309
+ return text({ words: words(md), sections: rows, ledger: parseLedger(md) });
310
+ });
311
+
312
+ server.tool('write_task', 'Write or overwrite a task file under tasks/ (the request the harness plans against).', { name: z.string().regex(/^[a-z0-9-]+$/), content: z.string() }, async ({ name, content }) => {
313
+ const p = join(work, 'tasks', `${name}.md`);
314
+ writeFileSync(p, content);
315
+ return text({ written: p, words: words(content) });
316
+ });
317
+
318
+ const transport = new StdioServerTransport();
319
+ await server.connect(transport);
@@ -0,0 +1,186 @@
1
+ // v6 §6: the public-naming lint. Scans the whole repository for name
2
+ // patterns from a forbidden-name blocklist and fails loudly if any appear
3
+ // anywhere in the shipped tree.
4
+ //
5
+ // The engineering problem this module exists to solve: the lint needs to
6
+ // know the forbidden names to check for them, but the whole point is that a
7
+ // public MIT repo must never carry those names itself - not even hashed
8
+ // alongside a "these are forbidden" comment, since a hash next to a plain
9
+ // list of five approved names is still a place a real name could get pasted
10
+ // by accident. So this module ships knowing NOTHING about any specific
11
+ // third-party name. It is a generic scanner over SHA-256 hashes of
12
+ // lowercase, whitespace-normalized candidate name spans:
13
+ //
14
+ // - DEFAULT_FORBIDDEN_HASHES here is empty. This repo's own author has an
15
+ // internal, third-party-derived naming shorthand (Dune character names,
16
+ // per the plan this module was built from) - that list is never typed
17
+ // into this file, this repo, or any session's output. It lives only in
18
+ // the author's own local, gitignored config (see loadForbiddenHashes()).
19
+ // - An operator adds their own forbidden set - their own trademark risks,
20
+ // not just this author's - by pointing COUNCIL_FORBIDDEN_NAMES_FILE (or
21
+ // the `forbiddenNamesFile` option) at a local JSON file of plain-text
22
+ // names. This module hashes them at load time; only the hash ever
23
+ // participates in a comparison after that point.
24
+ // - Without an operator-local file, the scan runs against the (empty)
25
+ // default set and finds nothing - which is honest: there is nothing to
26
+ // check against until an operator supplies their own list. The lint
27
+ // test in this repo's own suite exists to prove the MECHANISM works
28
+ // (via synthetic, clearly-fictional test names, never real ones), not
29
+ // to prove any specific name is absent - that guarantee is only as good
30
+ // as whatever forbidden list is actually loaded when it runs.
31
+ import { createHash } from 'node:crypto';
32
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
33
+ import { join, extname } from 'node:path';
34
+
35
+ export const DEFAULT_FORBIDDEN_HASHES = Object.freeze([]);
36
+ // COUNCIL_FORBIDDEN_NAMES_FILE takes any path you give it - nothing here
37
+ // enforces that it lives under .council/. That directory is only a
38
+ // convention this repo's .gitignore happens to cover; if you point this at
39
+ // a path outside it, gitignore gives you no protection and it's on you not
40
+ // to `git add` it (bug-audit finding, 2026-09-13 - noted so it isn't
41
+ // mistaken for an enforced guarantee).
42
+ const ENV_FORBIDDEN_FILE = 'COUNCIL_FORBIDDEN_NAMES_FILE';
43
+
44
+ // Generated, vendored, or binary content - never shipped source a reader
45
+ // browses, and in runs/'s case, real user data this project's own rules
46
+ // already keep out of the repo. `.git` and `node_modules` are structural.
47
+ // Every entry here is also gitignored (see .gitignore), so a real checkout
48
+ // never has anything to skip in the first place - this list only matters
49
+ // for a working tree with local, uncommitted output sitting in it.
50
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'runs', '.council']);
51
+ // Extensions worth reading as text. Anything else (images, fonts, lockfiles'
52
+ // noise) is skipped rather than mis-decoded.
53
+ const TEXT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.json', '.md', '.html', '.txt', '.yml', '.yaml', '.sh']);
54
+
55
+ export function hashName(name) {
56
+ // NFC-normalize before hashing: an accented name typed in decomposed form
57
+ // (NFD) must hash identically to the same name in precomposed form (NFC),
58
+ // or a forbidden name could slip past the check purely by which Unicode
59
+ // form happened to get pasted in (bug-audit finding, 2026-09-13).
60
+ return createHash('sha256').update(String(name).trim().toLowerCase().normalize('NFC')).digest('hex');
61
+ }
62
+
63
+ /**
64
+ * The forbidden-hash set to scan against: DEFAULT_FORBIDDEN_HASHES (empty,
65
+ * see module comment) plus any names in an operator-local file, hashed here
66
+ * so the plain text never leaves this function. A missing or unreadable
67
+ * file degrades to the default set - never throws, matching this project's
68
+ * existing degrade-not-throw convention (spend.js, verdict-stats.js).
69
+ */
70
+ export function loadForbiddenHashes({ forbiddenNamesFile, readFile = defaultReadFile } = {}) {
71
+ const path = forbiddenNamesFile ?? process.env[ENV_FORBIDDEN_FILE];
72
+ const hashes = new Set(DEFAULT_FORBIDDEN_HASHES);
73
+ if (!path) return hashes;
74
+ try {
75
+ const names = JSON.parse(readFile(path));
76
+ if (Array.isArray(names)) for (const n of names) hashes.add(hashName(n));
77
+ } catch { /* no local list, or it's unreadable - scan with the default set */ }
78
+ return hashes;
79
+ }
80
+
81
+ function defaultReadFile(path) {
82
+ return readFileSync(path, 'utf8');
83
+ }
84
+
85
+ function walk(dir, out = []) {
86
+ let entries;
87
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
88
+ for (const entry of entries) {
89
+ if (SKIP_DIRS.has(entry.name)) continue;
90
+ const full = join(dir, entry.name);
91
+ if (entry.isDirectory()) { walk(full, out); continue; }
92
+ if (entry.isFile() && TEXT_EXTENSIONS.has(extname(entry.name))) out.push(full);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // One Unicode letter/hyphen word per match, with the line it starts on.
98
+ // \p{L} rather than A-Za-z (bug-audit finding, 2026-09-13): a name
99
+ // containing a diacritic must still tokenize as one word, not fragment at
100
+ // the accented character. No apostrophe in the class: a name sitting inside
101
+ // a JS/JSON string literal ('Zorblax Prime') or Markdown text would
102
+ // otherwise absorb the closing quote into the last word ("Prime'"),
103
+ // silently breaking the hash match against the clean name. The rare loss
104
+ // (an apostrophe-containing name like "O'Brien" splits into two spans) is a
105
+ // smaller price than every quoted occurrence failing to match at all.
106
+ const WORD_RE = /\p{L}[\p{L}-]*/gu;
107
+
108
+ function wordsWithLines(text) {
109
+ const words = [];
110
+ let line = 1;
111
+ let lastIndex = 0;
112
+ for (const m of text.matchAll(WORD_RE)) {
113
+ line += countNewlines(text, lastIndex, m.index);
114
+ lastIndex = m.index;
115
+ words.push({ word: m[0], line });
116
+ }
117
+ return words;
118
+ }
119
+
120
+ function countNewlines(text, from, to) {
121
+ let n = 0;
122
+ for (let i = from; i < to; i++) if (text[i] === '\n') n++;
123
+ return n;
124
+ }
125
+
126
+ // Candidate name spans: every run of 1-3 consecutive words (so a two-word
127
+ // name like an approved persona's own "Van Gogh" pattern, or a two-word
128
+ // forbidden name, is checked as a unit, not only word-by-word) plus every
129
+ // single word on its own - spans deliberately cross line boundaries (a
130
+ // two-word name split by a Markdown line-wrap must still be caught; bug-
131
+ // audit finding, 2026-09-13), reported under the line the span STARTS on.
132
+ // Over-generous on purpose - a scanner that only checked single words, or
133
+ // only within one line, could miss a real name; false positives cost
134
+ // nothing here since the comparison is against a specific hash set, not a
135
+ // heuristic.
136
+ function candidateSpans(words) {
137
+ const spans = [];
138
+ for (let i = 0; i < words.length; i++) {
139
+ spans.push({ span: words[i].word, line: words[i].line });
140
+ if (i + 1 < words.length) spans.push({ span: `${words[i].word} ${words[i + 1].word}`, line: words[i].line });
141
+ if (i + 2 < words.length) spans.push({ span: `${words[i].word} ${words[i + 1].word} ${words[i + 2].word}`, line: words[i].line });
142
+ }
143
+ return spans;
144
+ }
145
+
146
+ /**
147
+ * Scan every text file under `root` (recursing into every subdirectory -
148
+ * src/mcp/, src/ui/, docs/, chains/, tasks/, test/, all of it, not a
149
+ * hand-picked subset, per the exact regression a prior version of this
150
+ * lint hit) for any candidate name span whose hash is in `forbiddenHashes`
151
+ * - in file CONTENT and in every file/directory NAME on the path (a
152
+ * forbidden name used as a code-name for a file would otherwise never be
153
+ * flagged; bug-audit finding, 2026-09-13). Returns findings; never throws
154
+ * on an unreadable file or directory.
155
+ */
156
+ export function scanForForbiddenNames(root, forbiddenHashes) {
157
+ const hashes = forbiddenHashes instanceof Set ? forbiddenHashes : new Set(forbiddenHashes || []);
158
+ const findings = [];
159
+ if (!hashes.size) return { findings, filesScanned: 0 };
160
+
161
+ const files = walk(root);
162
+ for (const file of files) {
163
+ // kebab-case/snake_case filenames are the common real-world shape for a
164
+ // code-name-as-filename - "-"/"_" split into separate words here (unlike
165
+ // prose content, where a hyphen usually joins one compound word) so
166
+ // "zorblax-prime.js" tokenizes as "zorblax", "prime", not one fused word.
167
+ const pathText = file.slice(root.length).replace(/[-_]/g, ' ');
168
+ for (const { span } of candidateSpans(wordsWithLines(pathText))) {
169
+ if (hashes.has(hashName(span))) {
170
+ findings.push({ file, line: null, span, where: 'path' });
171
+ }
172
+ }
173
+ let text;
174
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
175
+ for (const { span, line } of candidateSpans(wordsWithLines(text))) {
176
+ if (hashes.has(hashName(span))) {
177
+ // Unlike the key-redaction scan (candidate 11), the matched text
178
+ // here is not a secret - it's a name already sitting in plain
179
+ // sight in the file being scanned, so naming it in the finding
180
+ // makes the failure fixable rather than a guessing game.
181
+ findings.push({ file, line, span, where: 'content' });
182
+ }
183
+ }
184
+ }
185
+ return { findings, filesScanned: files.length };
186
+ }