scripts/council.js
#!/usr/bin/env node
// council.js — the fact-checking lane, BUILT AND INACTIVE. Runs a multi-seat,
// multi-round deliberation on one claim and emits a verdict record. Without
// --activate it refuses and explains: nothing feeds the verified lane yet.
//
// node scripts/council.js --claim "..." --activate
//
// Council: <seats> seats of the council model, independent round 1, cross-
// examination round 2, chair synthesis. Verdicts land in verified/<date>/
// verdicts.jsonl; the full transcript in verified/sessions/<id>.json.
import fs from "node:fs";
import path from "node:path";
import {
appendJsonl, DATA_ROOT, hashId, loadConfig, logSpend, nowIso, parseArgs, runModel, utcDate,
} from "./lib.js";
const { args } = parseArgs(process.argv.slice(2), {
claim: { takes: "value", default: null },
activate: { takes: "none", default: false },
seats: { takes: "value", default: null },
});
if (!args.claim) { console.error("--claim is required"); process.exit(2); }
if (!args.activate) {
console.log(`council: INACTIVE by design. The verified lane is not fed yet.
This harness is complete and budget-checked; activating it is a deliberate act:
node scripts/council.js --claim "..." --activate
Until then the site renders the verified lane as in-development, and every
emergence record stays status: unverified.`);
process.exit(3);
}
const config = loadConfig();
const model = config.council_model;
const seats = args.seats ? parseInt(args.seats, 10) : config.council_seats;
const councilPrompt = fs.readFileSync(path.join(import.meta.dirname, "../prompts/council.md"), "utf8");
const id = hashId("vrd", args.claim);
const date = utcDate();
const sessionFile = path.join(DATA_ROOT, "verified", "sessions", `${id}.json`);
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
const session = {
id, claim: args.claim, model, seats, rounds: config.council_rounds,
started: nowIso(), turns: [], totals: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
};
async function seatCall(seatNo, messages, temperature) {
const r = await runModel(model, messages, { temperature, max_tokens: 1200 });
logSpend({ script: "council", model, tokensIn: r.tokensIn, tokensOut: r.tokensOut, cost: r.cost });
session.totals.tokens_in += r.tokensIn || 0;
session.totals.tokens_out += r.tokensOut || 0;
session.totals.cost_usd += r.cost || 0;
session.turns.push({ seat: seatNo, content: r.text, tokens_in: r.tokensIn, tokens_out: r.tokensOut });
fs.writeFileSync(sessionFile, JSON.stringify(session, null, 1) + "\n");
return r.text;
}
console.log(`council: ${seats} seats, ${config.council_rounds} rounds, model ${model}`);
console.log(`claim: ${args.claim}`);
// Round 1 — independent verdicts
const round1 = [];
for (let s = 1; s <= seats; s++) {
const t = await seatCall(s, [
{ role: "system", content: councilPrompt },
{ role: "user", content: `Round 1 (independent). The claim:\n\n"${args.claim}"` },
], 0.7);
round1.push(t);
console.log(` seat ${s} round 1 done`);
}
// Round 2 — cross-examination
const finals = [];
for (let s = 1; s <= seats; s++) {
const others = round1.filter((_, i) => i !== s - 1).map((t, i) => `Seat ${i < s - 1 ? i + 1 : i + 2}:\n${t}`).join("\n\n");
const t = await seatCall(s, [
{ role: "system", content: councilPrompt },
{ role: "user", content: `Round 1 (independent). The claim:\n\n"${args.claim}"\n\nYou answered:\n${round1[s - 1]}` },
{ role: "user", content: `Round 2 (cross-examination). The other seats' round-1 verdicts:\n\n${others}\n\nRevise or hold. End with "FINAL STANCE:" and "FINAL CONFIDENCE:".` },
], 0.3);
finals.push(t);
console.log(` seat ${s} round 2 done`);
}
// Chair synthesis
const chairText = await seatCall(0, [
{ role: "system", content: "You are the chair of a fact-checking council. Synthesize the seats' final verdicts into ONE verdict record. Output only JSON: {\"stance\": \"supported|refuted|unclear\", \"confidence\": 0-1, \"reasoning\": \"3-6 sentences\", \"evidence_needed\": [\"...\"], \"dissent\": \"one line or null\"}. Majority stance wins; record genuine dissent." },
{ role: "user", content: `Claim:\n"${args.claim}"\n\nSeats' final verdicts:\n${finals.map((t, i) => `Seat ${i + 1}:\n${t}`).join("\n\n")}` },
], 0.2);
let verdict;
try {
const a = chairText.indexOf("{"), b = chairText.lastIndexOf("}");
verdict = JSON.parse(chairText.slice(a, b + 1));
} catch {
console.error("council: chair synthesis failed to parse — no verdict written");
process.exit(1);
}
const statusMap = { supported: "verified", refuted: "refuted", unclear: "unclear" };
const record = {
id,
status: statusMap[verdict.stance] || "unclear",
lane: "council",
claim: args.claim,
verdict: {
stance: verdict.stance || "unclear",
confidence: typeof verdict.confidence === "number" ? verdict.confidence : null,
reasoning: String(verdict.reasoning || ""),
evidence_needed: Array.isArray(verdict.evidence_needed) ? verdict.evidence_needed.map(String) : [],
dissent: verdict.dissent || null,
},
council: { model, seats, rounds: config.council_rounds, tokens_in: session.totals.tokens_in, tokens_out: session.totals.tokens_out, cost_usd: session.totals.cost_usd },
session: `verified/sessions/${id}.json`,
deliberated_at: nowIso(),
};
appendJsonl(path.join(DATA_ROOT, "verified", date, "verdicts.jsonl"), record);
fs.writeFileSync(sessionFile, JSON.stringify(session, null, 1) + "\n");
console.log(`council verdict: ${record.status} (confidence ${verdict.confidence}) — $${session.totals.cost_usd.toFixed(4)}, session archived`);