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,38 @@
1
+ // Partial-deliverable detection (v2 plan §7.1, GLM-6 primary design with DEEPSEEK-6's
2
+ // schema/required-field validation approach).
3
+ //
4
+ // Same class of bug as the lab-dropout incident this project already fixed: a stage's
5
+ // submitted deliverable is missing a required section, is truncated JSON, or fails
6
+ // structural validation, but the harness marks the stage complete anyway and passes it
7
+ // downstream - a broken foundation that still looks complete, propagating further before
8
+ // anyone notices. This validates a stage's raw output against the same required_sections
9
+ // field the stage contract (§2.2) already declares, reused for free.
10
+ //
11
+ // This is a defense-in-depth check at the disk-persistence boundary in src/cli.js, not a
12
+ // change to chain.js's own control flow - the debate/proposal/reply/blind-panel mechanism
13
+ // is untouched (§11), and this module has no dependency on it.
14
+ import { parseJson } from './chain.js';
15
+ import { requiredSectionsFor, isStructuredStage } from './stage-contract.js';
16
+
17
+ /**
18
+ * Check one stage's raw output text against its declared required sections.
19
+ * Structured (JSON-returning) stages: every required_sections key must be present in the
20
+ * parsed object. Freeform (text-returning) stages: the text must be non-empty - required
21
+ * sections there name a single symbolic slot, not literal JSON keys to look up.
22
+ */
23
+ export function validateDeliverable(stageKind, text) {
24
+ const required = requiredSectionsFor(stageKind);
25
+ if (!isStructuredStage(stageKind)) {
26
+ if (typeof text === 'string' && text.trim().length > 0) return { ok: true };
27
+ return { ok: false, missing: required, reason: 'empty or missing deliverable text' };
28
+ }
29
+ const parsed = parseJson(text);
30
+ if (!parsed || typeof parsed !== 'object') {
31
+ return { ok: false, missing: required, reason: 'deliverable is not readable as JSON' };
32
+ }
33
+ const missing = required.filter(key => parsed[key] === undefined);
34
+ if (missing.length) {
35
+ return { ok: false, missing, reason: `missing required field(s): ${missing.join(', ')}` };
36
+ }
37
+ return { ok: true };
38
+ }
@@ -0,0 +1,98 @@
1
+ // Peer-session dispatch (v3 plan §1, GLM-1 accepted, MISTRAL-1 cut - see PLAN.md's Scope
2
+ // ledger). Nine real runs were driven by handing an external stage to an already-running peer
3
+ // Claude Code session, not to a subagent - the harness had no record of this at all. A claim
4
+ // file, `runs/<id>/<stage>.claim.json`, is written by whoever intends to answer a stage, before
5
+ // it writes `<stage>.md`:
6
+ //
7
+ // { "claimed_by": "cnc-harness-a7", "claimed_at": "2026-09-13T12:46:10.000Z" }
8
+ //
9
+ // `claimed_by` is a self-declared string, nothing more - this is BYOK, local-disk software with
10
+ // no identity infrastructure, and this module does not invent one. It buys staleness detection
11
+ // (a claim with no answer, older than a timeout) and contested-claim detection (two different
12
+ // claimants before an answer lands), never "who is right" - the harness cannot verify that, so
13
+ // it only makes the collision visible.
14
+ //
15
+ // File-based only, no subprocess spawning, no network call, no ledger: this module reads and
16
+ // writes exactly one JSON file per stage, inside the run folder it already belongs to.
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ const DEFAULT_TIMEOUT_MS = 4 * 60 * 60 * 1000; // 4 hours - operator-tunable via the options arg,
21
+ // matching how MAX_USD_PER_RUN is a plain value the caller resolves before calling in, not a
22
+ // second config file this module reads for itself.
23
+
24
+ function claimPath(dir, stage) {
25
+ return join(dir, `${stage}.claim.json`);
26
+ }
27
+
28
+ function answerPath(dir, stage) {
29
+ return join(dir, `${stage}.md`);
30
+ }
31
+
32
+ function readClaim(dir, stage) {
33
+ const p = claimPath(dir, stage);
34
+ if (!existsSync(p)) return null;
35
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
36
+ }
37
+
38
+ /**
39
+ * Write a claim for a stage, the whole mechanism for declaring one (same posture as
40
+ * AMENDMENTS.md, §2: writing it is the entire path). Called by whoever intends to answer the
41
+ * stage, before it writes `<stage>.md`.
42
+ *
43
+ * If a claim already exists for this stage, its `claimed_by` differs from this one, and no
44
+ * answer has landed yet, this is a contested claim: the harness cannot decide which claimant is
45
+ * "correct", so the original claim's `claimed_by`/`claimed_at` are kept as the record of first
46
+ * claim and the new claimant is appended to `contested_by`, rather than silently overwritten -
47
+ * the same posture as `duplicate_answer` below for the answer file itself. Otherwise (no
48
+ * existing claim, the same claimant re-claiming, or the stage already has an answer - a fresh
49
+ * work cycle) this simply (re)writes the claim.
50
+ */
51
+ export function writeClaim(dir, stage, claimedBy, { now = () => new Date() } = {}) {
52
+ const existing = readClaim(dir, stage);
53
+ const hasAnswer = existsSync(answerPath(dir, stage));
54
+ let claim;
55
+ if (existing && existing.claimed_by !== claimedBy && !hasAnswer) {
56
+ const contested_by = [...(existing.contested_by || []), claimedBy];
57
+ claim = { ...existing, contested_by };
58
+ } else {
59
+ claim = { claimed_by: claimedBy, claimed_at: now().toISOString() };
60
+ }
61
+ writeFileSync(claimPath(dir, stage), JSON.stringify(claim, null, 2));
62
+ return claim;
63
+ }
64
+
65
+ export function readClaimFor(dir, stage) {
66
+ return readClaim(dir, stage);
67
+ }
68
+
69
+ /**
70
+ * Staleness and contest detection for one stage's claim.
71
+ *
72
+ * - No claim at all: no warning.
73
+ * - The claim was contested (see `writeClaim`) and no `<stage>.md` exists yet: `contested_claim`.
74
+ * - A claim exists, no `<stage>.md` yet, and `claimed_at` is older than `timeoutMs`:
75
+ * `stalled_claim` - safe for a third party to pick the stage up.
76
+ * - A claim exists and `<stage>.md` already exists: no warning (the claim did its job).
77
+ *
78
+ * Returns `null` (no warning) or `{ type, stage, ... }`.
79
+ */
80
+ export function checkClaimStaleness(dir, stage, { now = () => new Date(), timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
81
+ const claim = readClaim(dir, stage);
82
+ if (!claim) return null;
83
+ const hasAnswer = existsSync(answerPath(dir, stage));
84
+
85
+ if (claim.contested_by?.length && !hasAnswer) {
86
+ return { type: 'contested_claim', stage, claimed_by: claim.claimed_by, contested_by: claim.contested_by };
87
+ }
88
+
89
+ if (hasAnswer) return null;
90
+
91
+ const claimedAt = Date.parse(claim.claimed_at);
92
+ if (Number.isNaN(claimedAt)) return null; // an unparsable claimed_at is not this check's job to flag
93
+ const ageMs = now().getTime() - claimedAt;
94
+ if (ageMs > timeoutMs) {
95
+ return { type: 'stalled_claim', stage, claimed_by: claim.claimed_by, claimed_at: claim.claimed_at, ageMs };
96
+ }
97
+ return null;
98
+ }
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // v6 §6: public naming. The shipped default persona set - five historical/
4
+ // public-domain figures, chosen so a public MIT repo carrying the author's
5
+ // real name and a German Impressum never ships a third-party trademark. An
6
+ // operator may replace the whole set (never edit this file to do it - see
7
+ // loadPersonas()).
8
+ //
9
+ // Persona names are referenced only through this module in every
10
+ // prompt-assembly path - never hard-coded elsewhere (src/roles.js included).
11
+ // Wording below is a first pass, pending author review, same as §1's LENSES
12
+ // instruction text.
13
+ export const DEFAULT_PERSONAS = Object.freeze({
14
+ moses: Object.freeze({ name: 'Moses', voice: 'Speaks in plain, weighty directives. States the rule first, the reason second.' }),
15
+ noah: Object.freeze({ name: 'Noah', voice: 'Methodical and provisioning-minded. Asks what happens if this is wrong before asking what happens if it is right.' }),
16
+ matthew: Object.freeze({ name: 'Matthew', voice: 'A record-keeper\'s voice. Cites exactly what was said and by whom before drawing a conclusion.' }),
17
+ 'van-gogh': Object.freeze({ name: 'Van Gogh', voice: 'Sees the whole composition before the detail. Argues from what the piece is trying to be, not just what is wrong with one brushstroke.' }),
18
+ parzival: Object.freeze({ name: 'Parzival', voice: 'Asks the question everyone else assumed was already answered.' }),
19
+ });
20
+
21
+ // Takes any path - .council/ (see .gitignore) is only a suggested location,
22
+ // not enforced. Putting your own persona file somewhere else is fine, but
23
+ // gitignore won't protect it there; that's on you to keep out of git add.
24
+ const ENV_PERSONAS_FILE = 'COUNCIL_PERSONAS_FILE';
25
+
26
+ /**
27
+ * The persona set to use: an operator-supplied file (path argument, or the
28
+ * COUNCIL_PERSONAS_FILE env var) REPLACES the whole default set, matching
29
+ * §6 ("an operator may replace the whole set"). No file configured, or the
30
+ * file can't be read, falls back to DEFAULT_PERSONAS - never throws.
31
+ */
32
+ export function loadPersonas({ personasFile, readFile = defaultReadFile } = {}) {
33
+ const path = personasFile ?? process.env[ENV_PERSONAS_FILE];
34
+ if (!path) return DEFAULT_PERSONAS;
35
+ try {
36
+ const parsed = JSON.parse(readFile(path));
37
+ return parsed && typeof parsed === 'object' ? parsed : DEFAULT_PERSONAS;
38
+ } catch {
39
+ return DEFAULT_PERSONAS;
40
+ }
41
+ }
42
+
43
+ function defaultReadFile(path) {
44
+ return readFileSync(path, 'utf8');
45
+ }
46
+
47
+ /**
48
+ * Resolve a `role.persona` key (§1) against a persona set. Returns null for
49
+ * an unknown key rather than throwing - validating that null and raising a
50
+ * config error is §1's job (MISTRAL-2's validation rule), not this module's.
51
+ */
52
+ export function resolvePersona(key, personas = DEFAULT_PERSONAS) {
53
+ return personas?.[key] ?? null;
54
+ }
@@ -0,0 +1,84 @@
1
+ // Static pre-flight contract/criteria check (v2 plan §6, GLM-5 primary design,
2
+ // amended by DEEPSEEK-5/QWEN-3/MISTRAL-5 to warn rather than hard-fail).
3
+ //
4
+ // Before any stage of a run executes - before a single metered API call - compare the
5
+ // chain's own hard-required deliverable structure against the request/task text for
6
+ // keyword-level contradiction signals. Warn loudly, name both sides, never block the run.
7
+ //
8
+ // What this catches: the exact class of conflict this project has already hit once - a
9
+ // chain requiring an appended "Scope ledger" section (src/roles.js's proposal-mode build
10
+ // prompt) against text demanding a standalone, publication-ready document with no internal
11
+ // process record - using recognisable trigger words.
12
+ //
13
+ // What this deliberately misses, stated plainly: a rephrasing of the same semantic conflict
14
+ // without these exact words (e.g. "must be readable with no other file open") will not
15
+ // trigger any warning. This is a heuristic, not a guarantee, and this module does not claim
16
+ // otherwise - see test_preflight_known_misses_semantic_conflicts, which pins that the miss is
17
+ // known and accepted, not an oversight to "helpfully" patch into a false sense of completeness.
18
+ // A semantic/LLM-based check that would catch the missed case is an explicit non-goal (§11):
19
+ // it would add an API call and non-determinism to every run's start.
20
+ export const TRIGGER_KEYWORDS = ['standalone', 'without a', 'no appended', 'publication-ready', 'self-contained'];
21
+
22
+ /**
23
+ * Sections this chain's own build-stage prompt hard-requires in the deliverable, derived
24
+ * from chain config flags. The only current instance in this codebase: config.proposals
25
+ * mode requires the builder to end the deliverable with a "Scope ledger" section
26
+ * (src/roles.js proposerUser/proposalsSystem's "The plan must end with a section titled
27
+ * 'Scope ledger'" instruction). Extend this list if a future chain feature adds another
28
+ * hard-required section to a prompt in src/roles.js - keep it in sync with that file, not
29
+ * invented separately.
30
+ */
31
+ export function requiredDeliverableSections(chainConfig) {
32
+ const sections = [];
33
+ if (chainConfig?.proposals) sections.push('Scope ledger');
34
+ return sections;
35
+ }
36
+
37
+ /**
38
+ * Compare a chain's required sections against arbitrary text (the request/task text -
39
+ * the only text available before any API call, since acceptance criteria themselves are
40
+ * usually generated by a paid stage). Returns a list of warnings, never throws and never
41
+ * signals "block this run" - callers decide what to do with the list, but the harness
42
+ * itself only ever logs and proceeds.
43
+ */
44
+ export function preflightCheck(chainConfig, text) {
45
+ const sections = requiredDeliverableSections(chainConfig);
46
+ const lower = (text || '').toLowerCase();
47
+ const warnings = [];
48
+ for (const section of sections) {
49
+ for (const keyword of TRIGGER_KEYWORDS) {
50
+ if (lower.includes(keyword.toLowerCase())) {
51
+ warnings.push({
52
+ section,
53
+ keyword,
54
+ message: `Possible contract/criteria conflict: chain requires section '${section}'; criteria text contains '${keyword}', which may conflict. This is a heuristic check and may miss cases where the conflict is phrased differently. Proceeding - review manually if unsure.`,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ return warnings;
60
+ }
61
+
62
+ // v3 §3 (artifact inlining, ~/Projects/relay/tasks/thcmcp-v3-draft-fixed.md). Real incident:
63
+ // a task that only summarised a specific JSON rubric file, rather than inlining it, led every
64
+ // critic lab to invent plausible-but-nonexistent identifiers against it - only the external
65
+ // build seat, which could read the real file, caught it. This check warns (never blocks, same
66
+ // posture as preflightCheck above) when a task names a path it never fences verbatim elsewhere
67
+ // in its own text, so the operator can paste the real content before any labs see the task.
68
+ const PATH_PATTERN = /\b[\w.-]+\.[a-zA-Z]{1,6}\b/g;
69
+
70
+ export function checkArtifactReferences(text) {
71
+ const body = text || '';
72
+ const fenced = [...body.matchAll(/```[\s\S]*?```/g)].map(m => m[0]).join('\n');
73
+ const candidates = new Set([...body.matchAll(PATH_PATTERN)].map(m => m[0]));
74
+ const warnings = [];
75
+ for (const path of candidates) {
76
+ if (!fenced.includes(path)) {
77
+ warnings.push({
78
+ path,
79
+ message: `Possible missing artifact: the task names '${path}' but never fences its content verbatim anywhere in the task text. A lab reading only the task may invent plausible-but-nonexistent content for it. This is a heuristic check and may miss a path phrased unusually. Proceeding - inline the real file if this run is about it.`,
80
+ });
81
+ }
82
+ }
83
+ return warnings;
84
+ }
@@ -0,0 +1,120 @@
1
+ {
2
+ "_note": "USD per 1,000,000 tokens. List prices change often - re-check each lab's pricing page before quoting these to anyone. Verified against public pricing pages as of 2026-09-06; treat as estimates, not invoices.",
3
+ "anthropic/claude-opus-5": {
4
+ "in": 15.0,
5
+ "out": 75.0
6
+ },
7
+ "anthropic/claude-sonnet-5": {
8
+ "in": 3.0,
9
+ "out": 15.0
10
+ },
11
+ "anthropic/claude-haiku-4-5-20251001": {
12
+ "in": 1.0,
13
+ "out": 5.0
14
+ },
15
+ "openai/gpt-5": {
16
+ "in": 1.25,
17
+ "out": 10.0
18
+ },
19
+ "openai/gpt-5-mini": {
20
+ "in": 0.25,
21
+ "out": 2.0
22
+ },
23
+ "google/gemini-2.5-pro": {
24
+ "in": 1.25,
25
+ "out": 10.0
26
+ },
27
+ "google/gemini-2.5-flash": {
28
+ "in": 0.3,
29
+ "out": 2.5
30
+ },
31
+ "xai/grok-4": {
32
+ "in": 3.0,
33
+ "out": 15.0
34
+ },
35
+ "mistral/mistral-large-latest": {
36
+ "in": 2.0,
37
+ "out": 6.0
38
+ },
39
+ "deepseek/deepseek-chat": {
40
+ "in": 0.27,
41
+ "out": 1.1
42
+ },
43
+ "groq/llama-3.3-70b-versatile": {
44
+ "in": 0.59,
45
+ "out": 0.79
46
+ },
47
+ "together/Qwen/Qwen3.5-9B": {
48
+ "in": 0.1,
49
+ "out": 0.15
50
+ },
51
+ "mistral/mistral-small-latest": {
52
+ "in": 0.1,
53
+ "out": 0.3
54
+ },
55
+ "openai/gpt-5-nano": {
56
+ "in": 0.05,
57
+ "out": 0.4
58
+ },
59
+ "together/meta-llama/Llama-3.3-70B-Instruct-Turbo": {
60
+ "in": 0.88,
61
+ "out": 0.88
62
+ },
63
+ "deepseek/deepseek-reasoner": {
64
+ "in": 0.55,
65
+ "out": 2.19
66
+ },
67
+ "cohere/command-r7b-12-2024": {
68
+ "in": 0.0375,
69
+ "out": 0.15
70
+ },
71
+ "openrouter/meta-llama/llama-3.3-70b-instruct": {
72
+ "in": 0.1,
73
+ "out": 0.32
74
+ },
75
+ "together/deepseek-ai/DeepSeek-V3.1": {
76
+ "in": 0.6,
77
+ "out": 1.7
78
+ },
79
+ "google/gemini-3.6-flash": {
80
+ "in": 0.75,
81
+ "out": 3.75
82
+ },
83
+ "zai/glm-4.7-flash": {
84
+ "in": 0.0,
85
+ "out": 0.0
86
+ },
87
+ "together/deepseek-ai/DeepSeek-V4-Flash-0731": {
88
+ "in": 0.14,
89
+ "out": 0.28
90
+ },
91
+ "openrouter/qwen/qwen3.5-plus-20260420": {
92
+ "in": 0.3,
93
+ "out": 1.8
94
+ },
95
+ "openrouter/z-ai/glm-5.3-flash": {
96
+ "in": 0.07,
97
+ "out": 0.25
98
+ },
99
+ "openrouter/mistralai/mistral-large-2512": {
100
+ "in": 0.5,
101
+ "out": 1.5
102
+ },
103
+ "openrouter/minimax/minimax-m3": {
104
+ "in": 0.3,
105
+ "out": 1.2
106
+ },
107
+ "openrouter/deepseek/deepseek-v3.2": {
108
+ "in": 0.27,
109
+ "out": 0.4
110
+ },
111
+ "openrouter/moonshotai/kimi-k3": {
112
+ "in": 2.5,
113
+ "out": 14.0
114
+ },
115
+ "_test_note": "mock/* entries below are test fixtures, not real prices. The mock provider calls nothing; these exist only so the per-run spend cap can be exercised offline, since an unpriced seat cannot be capped.",
116
+ "mock/mock-priced": {
117
+ "in": 1000.0,
118
+ "out": 1000.0
119
+ }
120
+ }
@@ -0,0 +1,268 @@
1
+ // Provider adapters. One shape in, one shape out.
2
+ //
3
+ // call({ model, system, messages, maxTokens, temperature })
4
+ // -> { text, usage: { input, output }, provider, model }
5
+ //
6
+ // Anthropic uses its own Messages API. Everything else here speaks the
7
+ // OpenAI chat-completions shape, which is why they share one adapter.
8
+
9
+ const OPENAI_COMPAT = {
10
+ openai: { base: 'https://api.openai.com/v1', key: 'OPENAI_API_KEY' },
11
+ google: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', key: 'GOOGLE_API_KEY' },
12
+ xai: { base: 'https://api.x.ai/v1', key: 'XAI_API_KEY' },
13
+ mistral: { base: 'https://api.mistral.ai/v1', key: 'MISTRAL_API_KEY' },
14
+ deepseek: { base: 'https://api.deepseek.com/v1', key: 'DEEPSEEK_API_KEY' },
15
+ groq: { base: 'https://api.groq.com/openai/v1', key: 'GROQ_API_KEY' },
16
+ cohere: { base: 'https://api.cohere.ai/compatibility/v1', key: 'COHERE_API_KEY' },
17
+ openrouter: { base: 'https://openrouter.ai/api/v1', key: 'OPENROUTER_API_KEY' },
18
+ together: { base: 'https://api.together.xyz/v1', key: 'TOGETHER_API_KEY' },
19
+ zai: { base: 'https://api.z.ai/api/paas/v4', key: 'ZAI_API_KEY' },
20
+ };
21
+
22
+ const ANTHROPIC = { base: 'https://api.anthropic.com/v1', key: 'ANTHROPIC_API_KEY' };
23
+
24
+ // An offline provider used to test the chain's plumbing without spending
25
+ // anything. It fakes a builder and a critic well enough to exercise every
26
+ // branch, including the early stop.
27
+ async function callMock({ model, system, messages, maxTokens }) {
28
+ const user = messages.map(m => m.content).join('\n');
29
+ // A critic whose reply can't be parsed - stands in for a real reply cut
30
+ // off at the token cap. Exercises the abstention path offline.
31
+ if (model === 'mock-unreadable') {
32
+ await new Promise(r => setTimeout(r, 10));
33
+ return { text: 'Looks fine to me. (no JSON)', usage: { input: 10, output: 8 }, provider: 'mock', model };
34
+ }
35
+ // A critic whose provider errored out mid-generation with no content at all - stands in for
36
+ // GLM 5.3 Flash burning its whole token budget on thinking (usage.thinking === usage.output)
37
+ // and returning finish_reason: "error" (GP judging run 2026-09-10T20-01-59-545Z). Exercises the
38
+ // classifyUnreadable "stop: error" branch offline, distinct from the truncation/malformed-JSON
39
+ // cases mock-unreadable covers.
40
+ if (model === 'mock-provider-error') {
41
+ await new Promise(r => setTimeout(r, 10));
42
+ return { text: '', usage: { input: 10, output: 20, thinking: 20, stop: 'error' }, provider: 'mock', model };
43
+ }
44
+ if (system.startsWith('You are a proposer')) {
45
+ await new Promise(r => setTimeout(r, 10));
46
+ const text = model === 'mock-proposer-empty'
47
+ ? 'I have nothing to add.'
48
+ // v5 §1 candidate 10: a seat that returns more proposals than a
49
+ // chain's opt-in max_proposals_per_seat cap, to exercise the fold-down
50
+ // path offline. Four proposals, distinct titles so a merge test can
51
+ // tell which ones survived.
52
+ : model === 'mock-proposer-quad'
53
+ ? JSON.stringify({ proposals: [1, 2, 3, 4].map(n => (
54
+ { title: `Part ${n} from ${model}`, serves: 'the format criterion', what: 'A mock part.', why: 'Exercises the ledger.', how: 'mock.js', acceptance_test: 'It appears in the ledger.' }))})
55
+ : JSON.stringify({ proposals: [
56
+ { title: `Part from ${model}`, serves: 'the format criterion', what: 'A mock part.', why: 'Exercises the ledger.', how: 'mock.js', acceptance_test: 'It appears in the ledger.' },
57
+ ]});
58
+ return { text, usage: { input: 20, output: 30 }, provider: 'mock', model };
59
+ }
60
+ // v5 §1 candidate 10: the fold-down prompt itself. Keeps the first `cap`
61
+ // of the seat's own numbered proposals - enough to exercise the wiring
62
+ // offline without needing real judgement.
63
+ if (system.startsWith('You are folding your own proposals down')) {
64
+ await new Promise(r => setTimeout(r, 10));
65
+ const capMatch = user.match(/cap of (\d+)/);
66
+ const cap = capMatch ? Number(capMatch[1]) : 1;
67
+ const n = (user.match(/^## \d+$/gm) || []).length;
68
+ const kept = Array.from({ length: Math.min(cap, n) }, (_, i) => i + 1);
69
+ return { text: JSON.stringify({ kept, merged_because: 'mock: folded to the cap' }), usage: { input: 20, output: 10 }, provider: 'mock', model };
70
+ }
71
+ if (system.startsWith('You are one lab on a planning panel. Every lab proposed')) {
72
+ await new Promise(r => setTimeout(r, 10));
73
+ const others = [...user.matchAll(/^## ([A-Z]-\d+) \(by Lab [A-Z]\)/gm)].map(m => m[1]);
74
+ const mineIdx = user.indexOf('# The other labs');
75
+ const theirs = others.filter(id => user.indexOf(`## ${id} `) > mineIdx);
76
+ const posts = theirs.map((id, i) => ({ on: id, stance: i % 2 ? 'support' : 'object', text: i % 2 ? 'Fine as written.' : 'Quote: "mock.js" - no such file exists.' }));
77
+ return { text: JSON.stringify({ posts, revisions: [] }), usage: { input: 30, output: 30 }, provider: 'mock', model };
78
+ }
79
+ if (system.startsWith('You are one lab on a planning panel, answering')) {
80
+ await new Promise(r => setTimeout(r, 10));
81
+ const ids = [...user.matchAll(/^## ([A-Z]-\d+) \(by/gm)].map(m => m[1]);
82
+ const replies = ids.map((id, i) => i === 0 ? { id, action: 'amend', text: 'Fair.', how: 'mock.js (amended)' } : { id, action: 'keep', text: 'The file is created by the plan.' });
83
+ return { text: JSON.stringify({ replies }), usage: { input: 30, output: 20 }, provider: 'mock', model };
84
+ }
85
+ if (system.startsWith('You write the handoff file')) {
86
+ await new Promise(r => setTimeout(r, 10));
87
+ return { text: 'MOCK HANDOFF\n\nRead the plan. Start with "begin".', usage: { input: 30, output: 10 }, provider: 'mock', model };
88
+ }
89
+ if (system.startsWith('You read a request and ask the questions')) {
90
+ await new Promise(r => setTimeout(r, 10));
91
+ return { text: JSON.stringify({ questions: [
92
+ { question: 'Is the artifact for humans or machines?', why: 'Changes the format entirely.', default: 'Humans.' },
93
+ { question: 'One page or several?', why: 'Changes the structure.', default: 'One page.' },
94
+ ]}), usage: { input: 20, output: 40 }, provider: 'mock', model };
95
+ }
96
+ if (system.startsWith('You are the judge')) {
97
+ await new Promise(r => setTimeout(r, 10));
98
+ const n = (user.match(/^## \d+$/gm) || []).length;
99
+ return { text: JSON.stringify({ picks: n ? [1] : [], dropped_because: 'mock: duplicates' }), usage: { input: 20, output: 10 }, provider: 'mock', model };
100
+ }
101
+ if (system.startsWith('You write the skeleton')) {
102
+ await new Promise(r => setTimeout(r, 10));
103
+ return { text: 'MOCK SKELETON\n- system A\n- system B', usage: { input: 20, output: 10 }, provider: 'mock', model };
104
+ }
105
+ const isReviser = system.startsWith('You are the builder in a multi-model review chain,\nrevising');
106
+ const isCritic = !isReviser && system.startsWith('You are an independent critic');
107
+ const isCriteria = system.includes('turn a request into acceptance criteria');
108
+ let text;
109
+ if (isCriteria) {
110
+ text = JSON.stringify({ criteria: [
111
+ 'The deliverable is the artifact itself, not a plan for one.',
112
+ 'It states the assumptions it was written under.',
113
+ 'It adds no scope the request did not ask for.',
114
+ ]});
115
+ } else if (isCritic) {
116
+ // The scripted critic fails the first draft and passes the revision, so a
117
+ // test run exercises the revise path and then the early stop.
118
+ // `mock-critic-holdout` never passes, so a panel seated with it always ends
119
+ // on a real holdout - the branch where a run ships with an objection on the
120
+ // record rather than a unanimous sign-off.
121
+ const revised = model !== 'mock-critic-holdout' && user.includes('REVISED MOCK DELIVERABLE');
122
+ text = JSON.stringify(revised
123
+ ? { meets: true, criteria: [], failures: [], verdict_line: 'All criteria met.' }
124
+ : { meets: false, criteria: [], failures: [{ criterion: 'It states the assumptions it was written under.', problem: 'No assumptions section.', fix: 'Add one.' }], verdict_line: 'One criterion failed.' });
125
+ } else {
126
+ const ids = [...user.matchAll(/^## ([A-Z0-9-]+-\d+) \((?:from|[a-z0-9-]+\))/gm)].map(m => m[1]);
127
+ const ledger = ids.length ? `\n\n## Scope ledger\n${ids.map((id, i) => `${id} - ${i === 0 ? 'accepted - section 1' : 'cut - duplicate of the first'}`).join('\n')}` : '';
128
+ // test/chain.test.js's §5 dispute-record fixture: a reviser reply ending in one or more
129
+ // "DECLINED: <reason>" trailer lines, as src/roles.js's reviser system prompt now instructs
130
+ // for an objection the reviser judges not to be a real defect. Gated on a marker in the
131
+ // request text so every other mock-provider test (which never sets it) is unaffected.
132
+ const declineTrailer = isReviser && user.includes('TRIGGER_DECLINED_TEST')
133
+ ? '\n\nDECLINED: the critic quoted no evidence for this claim.\nDECLINED: this is a matter of taste, not a defect.'
134
+ : '';
135
+ text = `${isReviser ? 'REVISED ' : ''}MOCK DELIVERABLE for model ${model}\n\nBody text.${isReviser ? '\n\nAssumptions: none.' : ''}${ledger}${declineTrailer}`;
136
+ }
137
+ await new Promise(r => setTimeout(r, 10));
138
+ return { text, usage: { input: Math.ceil(user.length / 4), output: Math.ceil(text.length / 4) }, provider: 'mock', model };
139
+ }
140
+
141
+ export function keyFor(provider) {
142
+ if (provider === 'mock' || provider === 'external') return provider;
143
+ const spec = provider === 'anthropic' ? ANTHROPIC : OPENAI_COMPAT[provider];
144
+ if (!spec) throw new Error(`Unknown provider: ${provider}`);
145
+ return process.env[spec.key] || null;
146
+ }
147
+
148
+ export function providerNames() {
149
+ return ['anthropic', ...Object.keys(OPENAI_COMPAT)];
150
+ }
151
+
152
+ // The env var NAME a provider's key lives in - never the value. `council
153
+ // doctor` uses this to report which keys are present without ever reading
154
+ // (or printing) what they contain.
155
+ export function envKeyName(provider) {
156
+ if (provider === 'mock' || provider === 'external') return null;
157
+ const spec = provider === 'anthropic' ? ANTHROPIC : OPENAI_COMPAT[provider];
158
+ if (!spec) throw new Error(`Unknown provider: ${provider}`);
159
+ return spec.key;
160
+ }
161
+
162
+ async function withRetry(fn, { tries = 3, label = '' } = {}) {
163
+ let last;
164
+ for (let i = 0; i < tries; i++) {
165
+ try { return await fn(); }
166
+ catch (err) {
167
+ last = err;
168
+ // Do not burn retries on a request that will never succeed.
169
+ if (err.status && err.status !== 429 && err.status < 500) throw err;
170
+ const wait = 1500 * Math.pow(2, i);
171
+ process.stderr.write(` retry ${i + 1}/${tries} ${label}: ${err.message} (waiting ${wait}ms)\n`);
172
+ await new Promise(r => setTimeout(r, wait));
173
+ }
174
+ }
175
+ throw last;
176
+ }
177
+
178
+ async function post(url, headers, body, label) {
179
+ return withRetry(async () => {
180
+ const res = await fetch(url, {
181
+ method: 'POST',
182
+ headers: { 'content-type': 'application/json', ...headers },
183
+ body: JSON.stringify(body),
184
+ });
185
+ if (!res.ok) {
186
+ const detail = (await res.text()).slice(0, 600);
187
+ const err = new Error(`HTTP ${res.status} ${detail}`);
188
+ err.status = res.status;
189
+ throw err;
190
+ }
191
+ return res.json();
192
+ }, { label });
193
+ }
194
+
195
+ async function callAnthropic({ model, system, messages, maxTokens, temperature, extra }) {
196
+ const key = keyFor('anthropic');
197
+ if (!key) throw new Error('ANTHROPIC_API_KEY not set');
198
+ const json = await post(
199
+ `${ANTHROPIC.base}/messages`,
200
+ { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
201
+ {
202
+ model,
203
+ system,
204
+ messages: messages.map(m => ({ role: m.role, content: m.content })),
205
+ max_tokens: maxTokens,
206
+ ...(temperature === undefined ? {} : { temperature }),
207
+ // e.g. { thinking: { type: 'disabled' } } - Claude 5's thinking is
208
+ // adaptive by default, counts against max_tokens, and is not text.
209
+ ...(extra || {}),
210
+ },
211
+ `anthropic/${model}`
212
+ );
213
+ const text = (json.content || [])
214
+ .filter(b => b.type === 'text')
215
+ .map(b => b.text)
216
+ .join('');
217
+ return {
218
+ text,
219
+ usage: {
220
+ input: json.usage?.input_tokens ?? 0,
221
+ output: json.usage?.output_tokens ?? 0,
222
+ thinking: json.usage?.output_tokens_details?.thinking_tokens ?? 0,
223
+ stop: json.stop_reason ?? null,
224
+ },
225
+ provider: 'anthropic',
226
+ model,
227
+ };
228
+ }
229
+
230
+ async function callOpenAICompat(provider, { model, system, messages, maxTokens, temperature, extra }) {
231
+ const spec = OPENAI_COMPAT[provider];
232
+ const key = keyFor(provider);
233
+ if (!key) throw new Error(`${spec.key} not set`);
234
+ const json = await post(
235
+ `${spec.base}/chat/completions`,
236
+ { authorization: `Bearer ${key}` },
237
+ {
238
+ model,
239
+ messages: [{ role: 'system', content: system }, ...messages],
240
+ max_completion_tokens: maxTokens,
241
+ ...(temperature === undefined ? {} : { temperature }),
242
+ // Per-seat escape hatch for a provider's own non-standard fields (e.g.
243
+ // Qwen3.5's chat_template_kwargs.enable_thinking on Together). Kept
244
+ // generic rather than provider-specific branching in this adapter.
245
+ ...(extra || {}),
246
+ },
247
+ `${provider}/${model}`
248
+ );
249
+ return {
250
+ text: json.choices?.[0]?.message?.content ?? '',
251
+ usage: {
252
+ input: json.usage?.prompt_tokens ?? 0,
253
+ output: json.usage?.completion_tokens ?? 0,
254
+ thinking: json.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
255
+ stop: json.choices?.[0]?.finish_reason ?? null,
256
+ },
257
+ provider,
258
+ model,
259
+ };
260
+ }
261
+
262
+ export async function call(provider, opts) {
263
+ if (provider === 'mock') return callMock(opts);
264
+ if (provider === 'external') throw new Error('external seats are answered by writing <label>.md into the run folder, never called');
265
+ if (provider === 'anthropic') return callAnthropic(opts);
266
+ if (OPENAI_COMPAT[provider]) return callOpenAICompat(provider, opts);
267
+ throw new Error(`Unknown provider: ${provider}`);
268
+ }