From 290aba120aec9070c88f020f9e0928bb772cbd7e Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 14:15:06 +0200 Subject: [PATCH 01/19] build: add tsc --checkJs --noEmit typecheck gate for core --- .github/workflows/validate.yml | 3 +++ package.json | 2 ++ tsconfig.json | 16 ++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 tsconfig.json diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8c8a3a2..f80f950 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -27,6 +27,9 @@ jobs: with: node-version: '20' + - name: Typecheck + run: npm run typecheck + - name: Run bridge tests run: node --test test/*.test.js diff --git a/package.json b/package.json index 7842b11..389076a 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "test": "node --test test/*.test.js", + "typecheck": "npx -y -p typescript@5 tsc --noEmit", + "check": "npm run typecheck && npm test", "build:social": "npx -y playwright screenshot --viewport-size=1280,640 \"file://$(pwd)/assets/social-preview.html\" assets/social-preview.png" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4271a2e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["core/**/*.js", "server/mcp/**/*.js"], + "exclude": ["node_modules"] +} From fedd9c534fb3581cee602f07e6cb764414fc3b8e Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 14:15:06 +0200 Subject: [PATCH 02/19] feat(core): add DelegationRequest/Result/Provider typedefs --- core/types.js | 54 +++++++++++++++++++++++++++++++++++++++++ test/core-types.test.js | 9 +++++++ 2 files changed, 63 insertions(+) create mode 100644 core/types.js create mode 100644 test/core-types.test.js diff --git a/core/types.js b/core/types.js new file mode 100644 index 0000000..95f601d --- /dev/null +++ b/core/types.js @@ -0,0 +1,54 @@ +"use strict"; +// Pure JSDoc typedef module. Empty runtime export; the @typedefs feed the typecheck gate. + +/** + * @typedef {Object} FileRef + * @property {string} [path] + * @property {string} [dir] + * @property {string} [file_id] + * @property {string} [file_url] + * @property {("auto"|"inline"|"upload")} [mode] + */ + +/** + * @typedef {Object} DelegationRequest + * @property {string} prompt + * @property {string} [developerInstructions] + * @property {string} [cwd] + * @property {FileRef[]} [files] + * @property {("low"|"medium"|"high"|"none")} [reasoningEffort] + * @property {number} [temperature] + * @property {number} [timeoutMs] + * @property {string} [threadId] + * @property {string} [expert] + * @property {string} [model] + */ + +/** + * @typedef {Object} DelegationResult + * @property {string} provider + * @property {string} model + * @property {string} [text] + * @property {string} [threadId] + * @property {boolean} isError + * @property {string} [errorKind] + * @property {boolean} [retryable] + * @property {number} ms + */ + +/** + * @typedef {Object} ProviderCapabilities + * @property {boolean} canImplement + * @property {boolean} fileUpload + * @property {boolean} multiTurn + */ + +/** + * @typedef {Object} Provider + * @property {string} name + * @property {ProviderCapabilities} capabilities + * @property {() => Promise<{ok:boolean, reason?:string}>} health + * @property {(req: DelegationRequest) => Promise} ask + */ + +module.exports = {}; diff --git a/test/core-types.test.js b/test/core-types.test.js new file mode 100644 index 0000000..2ca6982 --- /dev/null +++ b/test/core-types.test.js @@ -0,0 +1,9 @@ +// test/core-types.test.js +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); + +test("T1: core/types.js loads without throwing (pure typedef module)", () => { + const m = require("../core/types.js"); + assert.equal(typeof m, "object"); +}); From dd9988b42803f59acc98187f77073692d5bb1f47 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 14:15:06 +0200 Subject: [PATCH 03/19] feat(core): add Provider contract, toErrorResult, opinion schema --- core/provider.js | 44 ++++++++++++++++++++++++++++++++++++++ test/core-provider.test.js | 28 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 core/provider.js create mode 100644 test/core-provider.test.js diff --git a/core/provider.js b/core/provider.js new file mode 100644 index 0000000..65aa85e --- /dev/null +++ b/core/provider.js @@ -0,0 +1,44 @@ +"use strict"; +/** @typedef {import("./types.js").DelegationResult} DelegationResult */ + +/** + * Normalize a thrown bridge error into a DelegationResult using that bridge's + * own classifier, so behavior matches the standalone bridge exactly. + * @param {string} name + * @param {string} model + * @param {number} started // Date.now() at call start + * @param {{status?:number, code?:string}} err + * @param {(status?:number, code?:string) => {errorKind:string, retryable:boolean}} classify + * @returns {DelegationResult} + */ +function toErrorResult(name, model, started, err, classify) { + const { errorKind, retryable } = classify(err && err.status, err && err.code); + return { provider: name, model, isError: true, errorKind, retryable, text: undefined, ms: Date.now() - started }; +} + +// Structured opinion schema shared across providers (Core minimum). +// Fast-follow extends with dissent_points/assumptions/tradeoffs. +const OPINION_SCHEMA = Object.freeze({ + type: "object", + required: ["recommendation", "confidence"], + properties: { + recommendation: { type: "string" }, + confidence: { type: "string", enum: ["low", "medium", "high"] }, + reasoning: { type: "string" }, + }, +}); + +/** + * Minimal runtime validation at the boundary (no JSON-schema dependency). + * @param {any} o + * @returns {{ok:boolean, reason?:string}} + */ +function validateOpinion(o) { + if (!o || typeof o !== "object") return { ok: false, reason: "opinion is not an object" }; + for (const k of OPINION_SCHEMA.required) { + if (!(k in o)) return { ok: false, reason: `missing required field: ${k}` }; + } + return { ok: true }; +} + +module.exports = { toErrorResult, OPINION_SCHEMA, validateOpinion }; diff --git a/test/core-provider.test.js b/test/core-provider.test.js new file mode 100644 index 0000000..4484100 --- /dev/null +++ b/test/core-provider.test.js @@ -0,0 +1,28 @@ +// test/core-provider.test.js +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { toErrorResult, OPINION_SCHEMA, validateOpinion } = require("../core/provider.js"); + +test("P1: toErrorResult normalizes a thrown error via the bridge classifier", () => { + const classify = (status) => ({ errorKind: status === 429 ? "rate-limit" : "unknown", retryable: status === 429 }); + const r = toErrorResult("openrouter", "x/y", Date.now() - 5, { status: 429 }, classify); + assert.equal(r.provider, "openrouter"); + assert.equal(r.model, "x/y"); + assert.equal(r.isError, true); + assert.equal(r.errorKind, "rate-limit"); + assert.equal(r.retryable, true); + assert.equal(r.text, undefined); + assert.ok(r.ms >= 0); +}); + +test("P2: OPINION_SCHEMA requires recommendation + confidence", () => { + assert.deepEqual(OPINION_SCHEMA.required, ["recommendation", "confidence"]); +}); + +test("P3: validateOpinion accepts a minimal opinion, rejects a missing field", () => { + assert.equal(validateOpinion({ recommendation: "ship it", confidence: "high" }).ok, true); + const bad = validateOpinion({ recommendation: "ship it" }); + assert.equal(bad.ok, false); + assert.match(bad.reason, /confidence/); +}); From f4288793a29b96e6193a721b857899e90dba0405 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 14:15:06 +0200 Subject: [PATCH 04/19] refactor(gemini): export runGemini + extract buildAgyArgs for core adapter --- server/gemini/index.js | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/server/gemini/index.js b/server/gemini/index.js index 6bc3cf6..fec74ed 100644 --- a/server/gemini/index.js +++ b/server/gemini/index.js @@ -29,6 +29,32 @@ function goDuration(ms) { return Math.ceil(ms / 1000) + "s"; } +// Build the agy argv for a one-shot (non-reply) run. MUST end with +// "-p " - runGemini does args.lastIndexOf("-p") to splice in +// --print-timeout before the prompt tail. The live `gemini` handler uses this +// so the server path and the core adapter share one assembly. model is accepted +// but never reaches argv (agy reads the model from ~/.gemini/settings.json). +// developerInstructions, when present, is folded into the prompt (agy print mode +// has no system channel). The --conversation reply flag is NOT built here; that +// stays in the gemini-reply handler branch. +/** + * @param {{prompt:string, model?:string, includeDirs?:string[], sandbox?:string, developerInstructions?:string}} req + * @returns {string[]} + */ +function buildAgyArgs(req) { + const args = []; + // Sandbox / permissions mapping (default read-only -> --sandbox). + if (req.sandbox === "workspace-write") args.push("--dangerously-skip-permissions"); + else args.push("--sandbox"); + // Extra workspace dirs. + for (const d of req.includeDirs || []) args.push("--add-dir", d); + // Fold expert instructions into the prompt (no system channel in print mode). + let prompt = req.prompt; + if (req.developerInstructions) prompt = `${req.developerInstructions}\n\n${prompt}`; + args.push("-p", prompt); // "-p " MUST be the tail + return args; +} + // --- MCP Protocol Helpers --- function sendResponse(id, result) { @@ -417,10 +443,13 @@ const handlers = { return; } - agyArgs.push(...sandboxFlags, ...addDirFlags); - let prompt = args.prompt; - if (args["developer-instructions"]) prompt = `${args["developer-instructions"]}\n\n${prompt}`; - agyArgs.push("-p", prompt); + agyArgs.push(...buildAgyArgs({ + prompt: args.prompt, + model: args.model, + includeDirs: args["include-directories"], + sandbox: args.sandbox, + developerInstructions: args["developer-instructions"], + })); } else if (name === "gemini-reply") { if (!isNonEmptyString(args.threadId)) { if (shouldRespond) sendError(id, -32602, "Invalid params: 'threadId' is required for gemini-reply"); @@ -537,4 +566,8 @@ if (typeof module !== "undefined" && module.exports) { module.exports.resolveConversationId = resolveConversationId; module.exports.goDuration = goDuration; module.exports.stdoutIsError = stdoutIsError; + + // Production exports (used by core adapters as well as tests) + module.exports.runGemini = runGemini; + module.exports.buildAgyArgs = buildAgyArgs; } From 72bdbbd0040d849a8e7b8e84ff41b566a118f01f Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:39 +0200 Subject: [PATCH 05/19] feat(core): server-side askAll fan-out + minimal consensus (isolated, fail-safe) --- core/orchestrate.js | 95 +++++++++++++++++++++++++++++++++++ test/core-orchestrate.test.js | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 core/orchestrate.js create mode 100644 test/core-orchestrate.test.js diff --git a/core/orchestrate.js b/core/orchestrate.js new file mode 100644 index 0000000..f76aab8 --- /dev/null +++ b/core/orchestrate.js @@ -0,0 +1,95 @@ +"use strict"; +/** @typedef {import("./types.js").Provider} Provider */ +/** @typedef {import("./types.js").DelegationRequest} DelegationRequest */ +/** @typedef {import("./types.js").DelegationResult} DelegationResult */ + +/** + * Fan out ONE request to N providers concurrently. The whole serialization fix: + * the host harness sees a single tool call, so it cannot stagger the providers. + * Failures are isolated - the batch never rejects. + * @param {Provider[]} providers + * @param {DelegationRequest} req + * @returns {Promise} + */ +async function askAll(providers, req) { + const settled = await Promise.allSettled( + providers.map((/** @type {Provider} */ p) => + p.ask({ ...req, files: req.files ? req.files.map((f) => ({ ...f })) : undefined }) + ) + ); + return settled.map((s, i) => + s.status === "fulfilled" + ? s.value + : { + provider: providers[i].name, + model: "unknown", + isError: true, + errorKind: "unknown", + retryable: false, + text: undefined, + message: String((s.reason && s.reason.message) || s.reason || "rejected"), + ms: 0, + } + ); +} + +/** + * Single-provider call (advisory one-shot). Shared entrypoint for ask-* tools. + * @param {Provider} provider + * @param {DelegationRequest} req + * @returns {Promise} + */ +async function askOne(provider, req) { + return provider.ask({ ...req, files: req.files ? req.files.map((f) => ({ ...f })) : undefined }); +} + +/** + * Assemble the arbiter prompt from independent opinions for blind cross-review. + * @param {string} question + * @param {DelegationResult[]} opinions // successful opinions only + * @returns {string} + */ +function buildArbiterPrompt(question, opinions) { + const blocks = opinions.map((o, i) => `### Opinion ${i + 1} - ${o.provider}\n${o.text}`).join("\n\n"); + return [ + "You are the arbiter. Below are independent expert opinions on the same question.", + "Cross-review them: note where they agree, where they disagree, and which view is best supported.", + "Then produce ONE synthesized verdict.", + "", + `## Original question\n${question}`, + "", + `## Opinions\n${blocks}`, + "", + "## Your verdict\nBottom line, points of agreement, points of disagreement, final recommendation.", + ].join("\n"); +} + +/** + * Minimal single-round advisory consensus: fan out to all providers, then run + * ONE arbiter pass over the successful opinions. No blind multi-round; the + * arbiter is just another Provider (default: the first in the set). + * @param {Provider[]} providers + * @param {DelegationRequest} req + * @param {{arbiter?:Provider, arbiterInstructions?:string}} [opts] + * @returns {Promise<{opinions:DelegationResult[], verdict:(DelegationResult|null), error?:string}>} + */ +async function consensus(providers, req, opts = {}) { + const opinions = await askAll(providers, req); + const ok = opinions.filter((o) => !o.isError && o.text); + if (!ok.length) return { opinions, verdict: null, error: "all-providers-failed" }; + const arbiter = opts.arbiter || providers[0]; + if (!arbiter) return { opinions, verdict: null, error: "no-arbiter" }; + try { + const verdict = await arbiter.ask({ + ...req, + files: req.files ? req.files.map((f) => ({ ...f })) : undefined, + prompt: buildArbiterPrompt(req.prompt, ok), + developerInstructions: opts.arbiterInstructions || req.developerInstructions, + }); + return { opinions, verdict }; + } catch { + return { opinions, verdict: null, error: "arbiter-failed" }; + } +} + +module.exports = { askAll, askOne, consensus, buildArbiterPrompt }; diff --git a/test/core-orchestrate.test.js b/test/core-orchestrate.test.js new file mode 100644 index 0000000..36b8386 --- /dev/null +++ b/test/core-orchestrate.test.js @@ -0,0 +1,88 @@ +// test/core-orchestrate.test.js +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { askAll, consensus } = require("../core/orchestrate.js"); +/** @typedef {import("../core/types.js").Provider} Provider */ + +/** @returns {Provider} */ +function fakeProvider(name, behavior) { + return /** @type {any} */ ({ + name, + capabilities: { canImplement: false, fileUpload: false, multiTurn: false }, + async health() { return { ok: true }; }, + async ask(req) { + if (behavior === "throw") throw new Error("boom"); + return { provider: name, model: "m", text: `${name}:${req.prompt}`, isError: false, ms: 1 }; + }, + }); +} + +test("O1: askAll returns one result per provider, in order", async () => { + const out = await askAll([fakeProvider("a"), fakeProvider("b")], { prompt: "hi" }); + assert.deepEqual(out.map((r) => r.provider), ["a", "b"]); + assert.equal(out[0].text, "a:hi"); +}); + +test("O2: a thrown provider becomes an isError result, never rejects the batch", async () => { + const out = await askAll([fakeProvider("ok"), fakeProvider("bad", "throw")], { prompt: "x" }); + assert.equal(out[0].isError, false); + assert.equal(out[1].isError, true); + assert.equal(out[1].provider, "bad"); + assert.equal(out[1].errorKind, "unknown"); +}); + +test("O3: each provider gets an independent copy of the request (zero contamination)", async () => { + /** @type {any[]} */ + const seen = []; + const a = /** @type {any} */ ({ name: "a", capabilities: {}, async health() { return { ok: true }; }, + async ask(req) { req.prompt += "!"; seen.push(req.prompt); return { provider: "a", model: "m", isError: false, ms: 0 }; } }); + const b = /** @type {any} */ ({ name: "b", capabilities: {}, async health() { return { ok: true }; }, + async ask(req) { seen.push(req.prompt); return { provider: "b", model: "m", isError: false, ms: 0 }; } }); + await askAll([a, b], { prompt: "p" }); + assert.deepEqual(seen, ["p!", "p"]); // b unaffected by a's mutation +}); + +test("C1: consensus fans out then runs ONE arbiter pass over the opinions", async () => { + const a = fakeProvider("a"), b = fakeProvider("b"); + const arbiter = /** @type {any} */ ({ name: "arb", capabilities: {}, async health() { return { ok: true }; }, + async ask(req) { + const sawBoth = req.prompt.includes("a:hi") && req.prompt.includes("b:hi"); + return { provider: "arb", model: "m", text: `verdict:${sawBoth}`, isError: false, ms: 0 }; + } }); + const out = await consensus([a, b], { prompt: "hi" }, { arbiter }); + assert.equal(out.opinions.length, 2); + assert.equal(out.verdict && out.verdict.text, "verdict:true"); // arbiter received both opinions in its prompt +}); + +test("C2: all-failed short-circuits with no arbiter call", async () => { + let called = false; + const arbiter = /** @type {any} */ ({ name: "arb", capabilities: {}, async health() { return { ok: true }; }, + async ask() { called = true; return { provider: "arb", model: "m", isError: false, ms: 0 }; } }); + const out = await consensus([fakeProvider("bad", "throw")], { prompt: "x" }, { arbiter }); + assert.equal(out.verdict, null); + assert.equal(out.error, "all-providers-failed"); + assert.equal(called, false); +}); + +test("C3: default arbiter is the first provider when none passed", async () => { + const out = await consensus([fakeProvider("a"), fakeProvider("b")], { prompt: "hi" }); + assert.equal(out.verdict && out.verdict.provider, "a"); // first provider arbitrates +}); + +test("C4: consensus is fail-safe when the arbiter throws", async () => { + const a = fakeProvider("a"), b = fakeProvider("b"); + const badArbiter = /** @type {any} */ ({ name: "arb", capabilities: {}, async health() { return { ok: true }; }, + async ask() { throw new Error("arbiter boom"); } }); + const out = await consensus([a, b], { prompt: "hi" }, { arbiter: badArbiter }); + assert.equal(out.verdict, null); + assert.equal(out.error, "arbiter-failed"); + assert.equal(out.opinions.length, 2); +}); + +test("C5: consensus with empty providers yields a safe shape, never throws", async () => { + const out = await consensus([], { prompt: "hi" }); + assert.equal(out.verdict, null); + // No opinions -> all-providers-failed guard fires before the no-arbiter guard. + assert.ok(out.error === "all-providers-failed" || out.error === "no-arbiter"); +}); From cc0e7c191866789265474071342fcdc79ca7f01e Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:39 +0200 Subject: [PATCH 06/19] feat(core): registry selection + per-alias OpenRouter expansion (closes issue 001 path) --- core/registry.js | 72 ++++++++++++++++++++++++++++++++++++++ test/core-registry.test.js | 60 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 core/registry.js create mode 100644 test/core-registry.test.js diff --git a/core/registry.js b/core/registry.js new file mode 100644 index 0000000..06f8c4a --- /dev/null +++ b/core/registry.js @@ -0,0 +1,72 @@ +"use strict"; +/** @typedef {import("./types.js").Provider} Provider */ + +// Selection semantics mirror server/openrouter/routing.js so the two stay +// behaviorally identical. eligibleForExpert: experts null/undefined => all; +// [] => none; else must include the expert. +function eligibleForExpert(model, expert) { + if (model.experts === null || model.experts === undefined) return true; + if (model.experts.length === 0) return false; + return model.experts.includes(expert); +} + +function askAllDelegates(or, expert) { + const pool = (or.models || []).filter((m) => m.askAll !== false && eligibleForExpert(m, expert)); + const cap = Number.isInteger(or.maxFanout) && or.maxFanout >= 1 ? or.maxFanout : 3; + return { selected: pool.slice(0, cap), omitted: pool.slice(cap) }; +} + +function consensusDelegates(or, expert) { + return (or.models || []).filter((m) => m.consensus === true && eligibleForExpert(m, expert)); +} + +const BUILTINS = ["codex", "gemini", "grok"]; + +// Wrap the single openrouter Provider as a per-alias Provider that pins the +// alias model and re-labels the result. This is the issue-001 fix: selection +// AND dispatch happen inside one server call, so the orchestrator never names +// an alias and a disabled one cannot leak from a stale cache. +function pinAlias(orProvider, delegate) { + return { + name: `openrouter:${delegate.alias}`, + capabilities: orProvider.capabilities, + health: orProvider.health.bind(orProvider), + async ask(req) { + const r = await orProvider.ask({ ...req, model: delegate.model }); + return { ...r, provider: `openrouter:${delegate.alias}` }; + }, + }; +} + +/** @param {Provider[]} providers */ +function makeRegistry(providers) { + const byName = new Map(providers.map((p) => [p.name, p])); + const enabled = (config, name) => { + const p = config && config.providers && config.providers[name]; + return !p || p.enabled !== false; // missing = enabled + }; + const builtinsFor = (config) => BUILTINS.filter((n) => byName.has(n) && enabled(config, n)).map((n) => byName.get(n)); + const pinDelegates = (delegates) => { + const orProvider = byName.get("openrouter"); + return orProvider ? delegates.map((d) => pinAlias(orProvider, d)) : []; + }; + + return { + get: (n) => byName.get(n), + + // Flat provider list ready for askAll(): built-ins + per-alias OR wrappers. + selectForAskAll({ config, expert }) { + const or = (config && config.openrouter) || {}; + const { selected, omitted } = askAllDelegates(or, expert); + return { providers: [...builtinsFor(config), ...pinDelegates(selected)], omitted }; + }, + + // Uncapped: built-ins + per-alias OR consensus delegates. + selectForConsensus({ config, expert }) { + const or = (config && config.openrouter) || {}; + return { providers: [...builtinsFor(config), ...pinDelegates(consensusDelegates(or, expert))] }; + }, + }; +} + +module.exports = { makeRegistry, eligibleForExpert, askAllDelegates, consensusDelegates, pinAlias }; diff --git a/test/core-registry.test.js b/test/core-registry.test.js new file mode 100644 index 0000000..577db6f --- /dev/null +++ b/test/core-registry.test.js @@ -0,0 +1,60 @@ +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { makeRegistry } = require("../core/registry.js"); + +/** @returns {import("../core/types.js").Provider} */ +function prov(name) { + return /** @type {any} */ ({ name, capabilities: {}, async health() { return { ok: true }; }, async ask() { return { provider: name, model: "m", isError: false, ms: 0 }; } }); +} + +const config = { + providers: { codex: { enabled: true }, gemini: { enabled: false }, grok: { enabled: true } }, + openrouter: { + maxFanout: 2, + models: [ + { alias: "all-on", model: "a/x", experts: null, askAll: true, consensus: true }, + { alias: "arch", model: "a/y", experts: ["architect"], askAll: true, consensus: false }, + { alias: "off", model: "a/z", experts: null, askAll: false, consensus: true }, + ], + }, +}; + +test("G1: get returns a registered provider by name", () => { + const reg = makeRegistry([prov("codex"), prov("grok")]); + assert.equal(reg.get("grok").name, "grok"); + assert.equal(reg.get("nope"), undefined); +}); + +test("G2: selectForAskAll = enabled built-ins + per-alias OR wrappers, capped", () => { + const reg = makeRegistry([prov("codex"), prov("gemini"), prov("grok"), prov("openrouter")]); + const { providers, omitted } = reg.selectForAskAll({ config, expert: "architect" }); + // gemini disabled; OR "off" excluded; cap 2 -> all-on, arch. Names carry the alias. + assert.deepEqual(providers.map((p) => p.name), ["codex", "grok", "openrouter:all-on", "openrouter:arch"]); + assert.deepEqual(omitted.map((m) => m.alias), []); +}); + +test("G3: a disabled (askAll:false) OR model is never in the fan-out - issue 001 regression", () => { + const reg = makeRegistry([prov("codex"), prov("openrouter")]); + const { providers } = reg.selectForAskAll({ config, expert: "architect" }); + assert.equal(providers.some((p) => p.name === "openrouter:off"), false); +}); + +test("G4: a per-alias OR wrapper injects the alias model into ask() and renames provider", async () => { + let gotModel; + const orp = /** @type {any} */ ({ name: "openrouter", capabilities: {}, async health() { return { ok: true }; }, + async ask(req) { gotModel = req.model; return { provider: "openrouter", model: req.model, isError: false, ms: 0 }; } }); + const reg = makeRegistry([orp]); + const { providers } = reg.selectForAskAll({ config, expert: "architect" }); + const wrapped = /** @type {any} */ (providers.find((p) => p.name === "openrouter:all-on")); + const r = await wrapped.ask({ prompt: "x" }); + assert.equal(gotModel, "a/x"); // alias model pinned + assert.equal(r.provider, "openrouter:all-on"); // result re-labelled with the alias +}); + +test("G5: selectForConsensus = built-ins + per-alias OR consensus delegates, uncapped", () => { + const reg = makeRegistry([prov("codex"), prov("gemini"), prov("grok"), prov("openrouter")]); + const { providers } = reg.selectForConsensus({ config, expert: "architect" }); + // consensus===true: all-on, off. (arch is consensus:false.) gemini disabled. Uncapped. + assert.deepEqual(providers.map((p) => p.name), ["codex", "grok", "openrouter:all-on", "openrouter:off"]); +}); From 1400dfe775385d9ec30bcf074907fa78163f8947 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:39 +0200 Subject: [PATCH 07/19] feat(core): openai-compatible adapter (bounded sessions) --- core/providers/openai-compatible.js | 59 +++++++++++++++++++++++++++++ test/core-openai-compatible.test.js | 50 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 core/providers/openai-compatible.js create mode 100644 test/core-openai-compatible.test.js diff --git a/core/providers/openai-compatible.js b/core/providers/openai-compatible.js new file mode 100644 index 0000000..8091cf5 --- /dev/null +++ b/core/providers/openai-compatible.js @@ -0,0 +1,59 @@ +"use strict"; +/** @typedef {import("../types.js").Provider} Provider */ +/** @typedef {import("../types.js").DelegationRequest} DelegationRequest */ +const crypto = require("node:crypto"); +const { toErrorResult } = require("../provider.js"); + +// Cap the in-memory session map so a long-running server cannot leak unbounded +// conversation history. Oldest (insertion-order) entry is evicted past the cap. +const MAX_SESSIONS = 100; + +/** + * @param {Object} opts + * @param {string} opts.name + * @param {string} opts.apiBase + * @param {string} opts.apiKeyEnv + * @param {(req:DelegationRequest)=>string} opts.resolveModel + * @param {Object} [opts.bridge] // injectable for tests; defaults to the real bridge + * @returns {Provider} + */ +function makeOpenAICompatibleProvider(opts) { + const { name = "openrouter", apiBase, apiKeyEnv, resolveModel } = opts; + // Cast the bridge to any: its CJS module.exports is typed as bare Object, and + // casting also stops tsc from deep-checking the legacy bridge transitively. + const bridge = /** @type {any} */ (opts.bridge || require("../../server/openrouter/index.js")); + const sessions = new Map(); // threadId -> turns + + return /** @type {any} */ ({ + name, + capabilities: { canImplement: false, fileUpload: false, multiTurn: true }, + /** Test-only: current number of cached sessions. */ + get __sessionCount() { return sessions.size; }, + async health() { + return process.env[apiKeyEnv] ? { ok: true } : { ok: false, reason: `${apiKeyEnv} unset` }; + }, + async ask(req) { + const started = Date.now(); + const model = resolveModel(req); + const prior = req.threadId && sessions.get(req.threadId); + const turns = prior + ? [...prior, { role: "user", text: req.prompt }] + : bridge.buildInitialTurns(req.developerInstructions, req.prompt, req.files || []); + try { + const { text } = await bridge.callOpenRouter({ + apiBase, apiKey: process.env[apiKeyEnv], model, + messages: bridge.buildMessages(turns), + reasoningEffort: req.reasoningEffort, temperature: req.temperature, timeoutMs: req.timeoutMs, + }); + const threadId = req.threadId || crypto.randomUUID(); + sessions.set(threadId, [...turns, { role: "assistant", text }]); + if (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value); + return { provider: name, model, text, threadId, isError: false, ms: Date.now() - started }; + } catch (e) { + return toErrorResult(name, model, started, /** @type {any} */ (e), bridge.classifyError); + } + }, + }); +} + +module.exports = { makeOpenAICompatibleProvider, MAX_SESSIONS }; diff --git a/test/core-openai-compatible.test.js b/test/core-openai-compatible.test.js new file mode 100644 index 0000000..cd3e6e9 --- /dev/null +++ b/test/core-openai-compatible.test.js @@ -0,0 +1,50 @@ +// test/core-openai-compatible.test.js +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { makeOpenAICompatibleProvider, MAX_SESSIONS } = require("../core/providers/openai-compatible.js"); + +const fakeBridge = { + callOpenRouter: async ({ model }) => ({ text: `reply from ${model}` }), + classifyError: (status) => ({ errorKind: status === 401 ? "auth" : "unknown", retryable: false }), + buildMessages: (turns) => turns, + buildInitialTurns: (sys, prompt) => [{ role: "system", text: sys }, { role: "user", text: prompt }], +}; + +test("OC1: ask returns a success DelegationResult with resolved model + threadId", async () => { + process.env.FAKE_KEY = "k"; + const p = makeOpenAICompatibleProvider({ name: "openrouter", apiBase: "http://x", apiKeyEnv: "FAKE_KEY", + resolveModel: () => "deepseek/deepseek-v4-pro", bridge: fakeBridge }); + const r = await p.ask({ prompt: "hi", developerInstructions: "be brief" }); + assert.equal(r.isError, false); + assert.equal(r.provider, "openrouter"); + assert.equal(r.model, "deepseek/deepseek-v4-pro"); + assert.equal(r.text, "reply from deepseek/deepseek-v4-pro"); + assert.ok(r.threadId); +}); + +test("OC2: health is false when the api key env is unset", async () => { + delete process.env.FAKE_KEY; + const p = makeOpenAICompatibleProvider({ name: "openrouter", apiBase: "http://x", apiKeyEnv: "FAKE_KEY", resolveModel: () => "m", bridge: fakeBridge }); + assert.equal((await p.health()).ok, false); +}); + +test("OC3: a thrown bridge call becomes a normalized error result", async () => { + process.env.FAKE_KEY = "k"; + const throwing = { ...fakeBridge, callOpenRouter: async () => { const e = /** @type {any} */ (new Error("nope")); e.status = 401; throw e; } }; + const p = makeOpenAICompatibleProvider({ name: "openrouter", apiBase: "http://x", apiKeyEnv: "FAKE_KEY", resolveModel: () => "m", bridge: throwing }); + const r = await p.ask({ prompt: "x" }); + assert.equal(r.isError, true); + assert.equal(r.errorKind, "auth"); +}); + +test("OC4: the session map is bounded at MAX_SESSIONS across many fresh calls", async () => { + process.env.FAKE_KEY = "k"; + const p = makeOpenAICompatibleProvider({ name: "openrouter", apiBase: "http://x", apiKeyEnv: "FAKE_KEY", + resolveModel: () => "m", bridge: fakeBridge }); + for (let i = 0; i < MAX_SESSIONS + 25; i++) { + const r = await p.ask({ prompt: `q${i}` }); // each fresh call -> new threadId + assert.ok(r.threadId); + } + assert.equal(/** @type {any} */ (p).__sessionCount, MAX_SESSIONS); +}); From 0a13fb61f0a28f30990fec7870b2b6597e6cbd01 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:39 +0200 Subject: [PATCH 08/19] feat(core): grok adapter (advisory, fileUpload) --- core/providers/grok.js | 51 ++++++++++++++++++++++++++++++++++++++++++ test/core-grok.test.js | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 core/providers/grok.js create mode 100644 test/core-grok.test.js diff --git a/core/providers/grok.js b/core/providers/grok.js new file mode 100644 index 0000000..552cfc3 --- /dev/null +++ b/core/providers/grok.js @@ -0,0 +1,51 @@ +"use strict"; +/** @typedef {import("../types.js").Provider} Provider */ +const { toErrorResult } = require("../provider.js"); + +/** + * @param {Object} [opts] + * @param {Object} [opts.bridge] + * @param {string} [opts.model] + * @param {string} [opts.apiBase] + * @returns {Provider} + */ +function makeGrokProvider(opts = {}) { + // The bridge's module.exports is typed as bare Object (untyped CJS export); + // cast to any so adapter property access typechecks. Runtime is unchanged. + const bridge = /** @type {any} */ (opts.bridge || require("../../server/grok/index.js")); + const model = opts.model || process.env.GROK_DEFAULT_MODEL || "grok-4.3"; + const apiBase = opts.apiBase || process.env.XAI_API_BASE || "https://api.x.ai/v1"; + + return { + name: "grok", + // multiTurn is not wired through Core (runGrok/runWithFiles return no threadId), + // so report false to match reality. + capabilities: { canImplement: false, fileUpload: true, multiTurn: false }, + async health() { + return process.env.XAI_API_KEY ? { ok: true } : { ok: false, reason: "XAI_API_KEY unset" }; + }, + async ask(req) { + const started = Date.now(); + const reasoningEffort = bridge.resolveReasoningEffort(req.reasoningEffort); + const apiKey = process.env.XAI_API_KEY; + try { + // runWithFiles builds its own turns from prompt + developer-instructions; + // runGrok takes pre-built turns. Both return { text, output }. + const out = (req.files && req.files.length) + ? await bridge.runWithFiles({ + files: req.files, prompt: req.prompt, "developer-instructions": req.developerInstructions, + apiKey, apiBase, model, reasoningEffort, timeout: req.timeoutMs, cwd: req.cwd, + }) + : await bridge.runGrok({ + turns: bridge.buildInitialTurns(req.developerInstructions, req.prompt, []), + model, apiKey, apiBase, reasoningEffort, timeoutMs: req.timeoutMs, + }); + return { provider: "grok", model, text: out.text, isError: false, ms: Date.now() - started }; + } catch (e) { + return toErrorResult("grok", model, started, /** @type {any} */ (e), bridge.classifyGrokError); + } + }, + }; +} + +module.exports = { makeGrokProvider }; diff --git a/test/core-grok.test.js b/test/core-grok.test.js new file mode 100644 index 0000000..3280914 --- /dev/null +++ b/test/core-grok.test.js @@ -0,0 +1,47 @@ +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { makeGrokProvider } = require("../core/providers/grok.js"); + +const fakeBridge = { + buildInitialTurns: (sys, prompt) => [{ role: "system", text: sys }, { role: "user", text: prompt }], + runGrok: async ({ model }) => ({ text: `grok ${model}`, output: null }), // real shape: { text, output } + runWithFiles: async ({ model }) => ({ text: `grok+files ${model}`, output: null, refs: [], ownedIds: [] }), + classifyGrokError: (status) => ({ errorKind: status === 401 ? "auth" : "unknown", retryable: false }), + resolveReasoningEffort: (e) => e || "high", +}; + +test("GK1: capabilities.fileUpload is true, multiTurn is false (not wired through Core)", () => { + const caps = makeGrokProvider({ bridge: fakeBridge }).capabilities; + assert.equal(caps.fileUpload, true); + assert.equal(caps.multiTurn, false); +}); + +test("GK2: ask with no files uses runGrok and returns success text", async () => { + process.env.XAI_API_KEY = "k"; + const p = makeGrokProvider({ bridge: fakeBridge, model: "grok-4.3" }); + const r = await p.ask({ prompt: "hi" }); + assert.equal(r.isError, false); + assert.equal(r.provider, "grok"); + assert.equal(r.text, "grok grok-4.3"); +}); + +test("GK3: ask WITH files routes through runWithFiles", async () => { + process.env.XAI_API_KEY = "k"; + const p = makeGrokProvider({ bridge: fakeBridge, model: "grok-4.3" }); + const r = await p.ask({ prompt: "hi", files: [{ path: "x.js", mode: "auto" }] }); + assert.equal(r.text, "grok+files grok-4.3"); +}); + +test("GK4: missing XAI_API_KEY -> health false", async () => { + delete process.env.XAI_API_KEY; + assert.equal((await makeGrokProvider({ bridge: fakeBridge }).health()).ok, false); +}); + +test("GK5: a thrown bridge call -> normalized error via classifyGrokError", async () => { + process.env.XAI_API_KEY = "k"; + const throwing = { ...fakeBridge, runGrok: async () => { const e = /** @type {any} */ (new Error("no")); e.status = 401; throw e; } }; + const r = await makeGrokProvider({ bridge: throwing }).ask({ prompt: "x" }); + assert.equal(r.isError, true); + assert.equal(r.errorKind, "auth"); +}); From a12f1f86272861e49997671be388ef84478ff5d5 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:40 +0200 Subject: [PATCH 09/19] feat(core): antigravity (gemini) adapter (real error classification) --- core/providers/antigravity.js | 50 ++++++++++++++++++++++++++++++++++ test/core-antigravity.test.js | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 core/providers/antigravity.js create mode 100644 test/core-antigravity.test.js diff --git a/core/providers/antigravity.js b/core/providers/antigravity.js new file mode 100644 index 0000000..7b14e83 --- /dev/null +++ b/core/providers/antigravity.js @@ -0,0 +1,50 @@ +"use strict"; +/** @typedef {import("../types.js").Provider} Provider */ +const { toErrorResult } = require("../provider.js"); + +/** + * @param {Object} [opts] + * @param {Object} [opts.bridge] + * @param {string} [opts.model] + * @returns {Provider} + */ +function makeAntigravityProvider(opts = {}) { + // Cast the bridge to any: its CJS module.exports is typed as bare Object, and + // casting also stops tsc from deep-checking the legacy bridge transitively. + const bridge = /** @type {any} */ (opts.bridge || require("../../server/gemini/index.js")); + const model = opts.model || process.env.GEMINI_DEFAULT_MODEL || "auto-gemini-3"; + + return { + name: "gemini", + capabilities: { canImplement: true, fileUpload: false, multiTurn: true }, + async health() { + return typeof bridge.runGemini === "function" ? { ok: true } : { ok: false, reason: "agy bridge unavailable" }; + }, + async ask(req) { + const started = Date.now(); + // buildAgyArgs(req) real signature: { prompt, model, sandbox, includeDirs, developerInstructions }. + // Pin sandbox:"read-only" so Core stays advisory; the bridge folds + // developerInstructions into the prompt when present (no system channel in agy print mode). + const args = bridge.buildAgyArgs({ + prompt: req.prompt, + model, + sandbox: "read-only", + developerInstructions: req.developerInstructions, + includeDirs: (req.files || []).filter((f) => f.dir).map((f) => f.dir), + }); + try { + // runGemini(args, cwd, timeoutMs, recoveryGraceMs). recovered:true => normal success. + const out = await bridge.runGemini(args, req.cwd, req.timeoutMs, undefined); + return { provider: "gemini", model, text: out.response, threadId: out.threadId, isError: false, ms: Date.now() - started }; + } catch (e) { + // classifyGeminiError(errMsg, errCode): the missing-cli and upstream-abort + // branches key off the message, so pass the real caught message - not "". + return toErrorResult("gemini", model, started, /** @type {any} */ (e), (_status, code) => + bridge.classifyGeminiError((e && e.message) || "", code) + ); + } + }, + }; +} + +module.exports = { makeAntigravityProvider }; diff --git a/test/core-antigravity.test.js b/test/core-antigravity.test.js new file mode 100644 index 0000000..aa1b5f6 --- /dev/null +++ b/test/core-antigravity.test.js @@ -0,0 +1,51 @@ +// test/core-antigravity.test.js +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { makeAntigravityProvider } = require("../core/providers/antigravity.js"); + +const fakeBridge = { + buildAgyArgs: (req) => ["--model", req.model || "auto-gemini-3", req.prompt], + runGemini: async () => ({ response: "gemini reply", threadId: "g-1", recovered: false }), + classifyGeminiError: () => ({ errorKind: "timeout", retryable: true }), +}; + +test("AG1: ask maps a clean run (response -> text) to a success result", async () => { + const r = await makeAntigravityProvider({ bridge: fakeBridge }).ask({ prompt: "hi", cwd: "/tmp" }); + assert.equal(r.isError, false); + assert.equal(r.provider, "gemini"); + assert.equal(r.text, "gemini reply"); + assert.equal(r.threadId, "g-1"); +}); + +test("AG2: recovered:true is still a success (drain), not an error", async () => { + const recov = { ...fakeBridge, runGemini: async () => ({ response: "late", threadId: "g-2", recovered: true }) }; + const r = await makeAntigravityProvider({ bridge: recov }).ask({ prompt: "x", cwd: "/tmp" }); + assert.equal(r.isError, false); + assert.equal(r.text, "late"); +}); + +test("AG3: thrown runGemini classifies from the real message, not an empty string", async () => { + // Mirror the message-keyed branches of the real classifyGeminiError so this + // test fails if the adapter ever hardcodes "" again (which silences missing-cli). + const classifyGeminiError = (errMsg, errCode) => { + const msg = String(errMsg || ""); + const lower = msg.toLowerCase(); + if (errCode === "timeout") return { errorKind: "timeout", retryable: true }; + if (msg.includes("(agy) not found")) return { errorKind: "missing-cli", retryable: false }; + if (lower.includes("aborterror") || lower.includes("aborted")) return { errorKind: "upstream-abort", retryable: true }; + return { errorKind: "unknown", retryable: false }; + }; + const throwing = { + ...fakeBridge, + classifyGeminiError, + runGemini: async () => { throw new Error("Antigravity CLI (agy) not found. Install from ..."); }, + }; + const r = await makeAntigravityProvider({ bridge: throwing }).ask({ prompt: "x", cwd: "/tmp" }); + assert.equal(r.isError, true); + assert.equal(r.errorKind, "missing-cli"); // would be "unknown" if message were dropped +}); + +test("AG4: capabilities.canImplement is true", () => { + assert.equal(makeAntigravityProvider({ bridge: fakeBridge }).capabilities.canImplement, true); +}); From 280a19d3c66cefc3b8dda9cf35596866fd1ea828 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:40 +0200 Subject: [PATCH 10/19] feat(core): codex adapter (Option A spawn, leak-safe) --- core/providers/codex.js | 79 +++++++++++++++++++++++++++++++++++++++++ test/core-codex.test.js | 32 +++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 core/providers/codex.js create mode 100644 test/core-codex.test.js diff --git a/core/providers/codex.js b/core/providers/codex.js new file mode 100644 index 0000000..355c2ea --- /dev/null +++ b/core/providers/codex.js @@ -0,0 +1,79 @@ +"use strict"; +/** @typedef {import("../types.js").Provider} Provider */ +const { spawn } = require("node:child_process"); + +/** + * Map codex stderr to the shared errorKind vocabulary. + * @param {string} [stderr] + * @returns {{errorKind:string, retryable:boolean}} + */ +function classifyCodex(stderr) { + const s = (stderr || "").toLowerCase(); + if (s.includes("auth") || s.includes("login")) return { errorKind: "auth", retryable: false }; + if (s.includes("timeout")) return { errorKind: "timeout", retryable: true }; + if (s.includes("rate")) return { errorKind: "rate-limit", retryable: true }; + return { errorKind: "unknown", retryable: false }; +} + +/** + * Default spawner: `codex exec` reading the prompt on stdin, capturing stdout. + * @param {{prompt:string, cwd?:string, timeoutMs?:number}} args + * @returns {Promise<{code:number, stdout:string, stderr:string}>} + */ +function defaultRun({ prompt, cwd, timeoutMs }) { + return new Promise((resolve) => { + const child = spawn("codex", ["exec", "--skip-git-repo-check"], { cwd: cwd || process.cwd() }); + let stdout = "", stderr = "", settled = false; + const timer = timeoutMs ? setTimeout(() => child.kill("SIGKILL"), timeoutMs) : null; + if (timer) timer.unref(); // never hold the event loop open on the timeout timer + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + child.on("error", (e) => { + if (settled) return; settled = true; + if (timer) clearTimeout(timer); + resolve({ code: 127, stdout: "", stderr: String((e && e.message) || e) }); + }); + child.on("close", (code) => { + if (settled) return; settled = true; + if (timer) clearTimeout(timer); + resolve({ code: code == null ? 1 : code, stdout, stderr }); + }); + child.stdin.end(prompt); + }); +} + +/** + * @param {Object} [opts] + * @param {(args:{prompt:string,cwd?:string,timeoutMs?:number})=>Promise<{code:number,stdout:string,stderr:string}>} [opts.run] + * @param {string} [opts.model] + * @returns {Provider} + */ +function makeCodexProvider(opts = {}) { + const run = opts.run || defaultRun; + const model = opts.model || "default"; // codex resolves its own model from config.toml + return { + name: "codex", + capabilities: { canImplement: true, fileUpload: false, multiTurn: false }, // Option A: no threadId continuity + async health() { return { ok: true }; }, + async ask(req) { + const started = Date.now(); + const full = req.developerInstructions ? `${req.developerInstructions}\n\n---\n\n${req.prompt}` : req.prompt; + const { code, stdout, stderr } = await run({ prompt: full, cwd: req.cwd, timeoutMs: req.timeoutMs }); + if (code === 0) { + return { provider: "codex", model, text: stdout.trim(), isError: false, ms: Date.now() - started }; + } + const { errorKind, retryable } = classifyCodex(stderr); + return { + provider: "codex", + model, + isError: true, + errorKind, + retryable, + text: (stdout && stdout.trim()) || undefined, // keep stdout-borne error detail + ms: Date.now() - started, + }; + }, + }; +} + +module.exports = { makeCodexProvider, classifyCodex }; diff --git a/test/core-codex.test.js b/test/core-codex.test.js new file mode 100644 index 0000000..347864d --- /dev/null +++ b/test/core-codex.test.js @@ -0,0 +1,32 @@ +// test/core-codex.test.js +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { makeCodexProvider } = require("../core/providers/codex.js"); + +test("CX1: ask returns the captured stdout as text on exit 0", async () => { + const p = makeCodexProvider({ run: async () => ({ code: 0, stdout: "codex says hi", stderr: "" }) }); + const r = await p.ask({ prompt: "hi" }); + assert.equal(r.isError, false); + assert.equal(r.provider, "codex"); + assert.equal(r.text, "codex says hi"); +}); + +test("CX2: a non-zero exit is a normalized error result", async () => { + const p = makeCodexProvider({ run: async () => ({ code: 1, stdout: "", stderr: "auth required" }) }); + const r = await p.ask({ prompt: "x" }); + assert.equal(r.isError, true); + assert.equal(r.errorKind, "auth"); +}); + +test("CX3: capabilities.canImplement true (Core still calls advisory only)", () => { + assert.equal(makeCodexProvider({ run: async () => ({ code: 0, stdout: "", stderr: "" }) }).capabilities.canImplement, true); +}); + +test("CX4: a non-zero exit surfaces stdout in .text (diagnostic detail not lost)", async () => { + const p = makeCodexProvider({ run: async () => ({ code: 1, stdout: "diagnostic detail from codex", stderr: "boom" }) }); + const r = await p.ask({ prompt: "x" }); + assert.equal(r.isError, true); + assert.equal(r.errorKind, "unknown"); + assert.equal(r.text, "diagnostic detail from codex"); +}); From 3b1187e4bb7af880a1a390d7f8aaddd523099635 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:01:40 +0200 Subject: [PATCH 11/19] build: real typecheck gate (typescript + @types/node devDeps) --- core/types.js | 1 + package-lock.json | 47 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +++++- tsconfig.json | 8 +++++--- 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 package-lock.json diff --git a/core/types.js b/core/types.js index 95f601d..ea773f2 100644 --- a/core/types.js +++ b/core/types.js @@ -32,6 +32,7 @@ * @property {string} [threadId] * @property {boolean} isError * @property {string} [errorKind] + * @property {string} [message] * @property {boolean} [retryable] * @property {number} ms */ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..98a6eee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "claude-delegator", + "version": "1.17.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-delegator", + "version": "1.17.0", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 389076a..d0d5967 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,12 @@ "private": true, "scripts": { "test": "node --test test/*.test.js", - "typecheck": "npx -y -p typescript@5 tsc --noEmit", + "typecheck": "tsc -p tsconfig.json", "check": "npm run typecheck && npm test", "build:social": "npx -y playwright screenshot --viewport-size=1280,640 \"file://$(pwd)/assets/social-preview.html\" assets/social-preview.png" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" } } diff --git a/tsconfig.json b/tsconfig.json index 4271a2e..35d6413 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,11 +6,13 @@ "allowJs": true, "checkJs": true, "noEmit": true, - "strict": true, + "strict": false, + "noImplicitAny": false, "skipLibCheck": true, "resolveJsonModule": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "types": ["node"] }, - "include": ["core/**/*.js", "server/mcp/**/*.js"], + "include": ["core/**/*.js", "server/mcp/**/*.js", "test/core-*.test.js"], "exclude": ["node_modules"] } From 420072fefa83eb948fd0dc68ff69e736e214388e Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:31:25 +0200 Subject: [PATCH 12/19] build: restore strict typecheck gate; @ts-nocheck legacy bridges --- core/providers/antigravity.js | 5 +- core/providers/openai-compatible.js | 2 +- core/registry.js | 73 ++++++++++++++++++++++++++--- server/gemini/index.js | 1 + server/grok/cache.js | 1 + server/grok/glob.js | 1 + server/grok/index.js | 1 + server/grok/lock.js | 1 + server/openrouter/config.js | 1 + server/openrouter/files.js | 1 + server/openrouter/index.js | 1 + server/openrouter/routing.js | 1 + test/core-antigravity.test.js | 4 +- test/core-codex.test.js | 2 +- test/core-grok.test.js | 12 ++--- test/core-openai-compatible.test.js | 10 ++-- test/core-orchestrate.test.js | 14 +++--- test/core-provider.test.js | 4 +- test/core-registry.test.js | 6 +-- tsconfig.json | 3 +- 20 files changed, 107 insertions(+), 37 deletions(-) diff --git a/core/providers/antigravity.js b/core/providers/antigravity.js index 7b14e83..5e5927e 100644 --- a/core/providers/antigravity.js +++ b/core/providers/antigravity.js @@ -39,8 +39,9 @@ function makeAntigravityProvider(opts = {}) { } catch (e) { // classifyGeminiError(errMsg, errCode): the missing-cli and upstream-abort // branches key off the message, so pass the real caught message - not "". - return toErrorResult("gemini", model, started, /** @type {any} */ (e), (_status, code) => - bridge.classifyGeminiError((e && e.message) || "", code) + const err = /** @type {any} */ (e); + return toErrorResult("gemini", model, started, err, (_status, code) => + bridge.classifyGeminiError((err && err.message) || "", code) ); } }, diff --git a/core/providers/openai-compatible.js b/core/providers/openai-compatible.js index 8091cf5..e232c51 100644 --- a/core/providers/openai-compatible.js +++ b/core/providers/openai-compatible.js @@ -32,7 +32,7 @@ function makeOpenAICompatibleProvider(opts) { async health() { return process.env[apiKeyEnv] ? { ok: true } : { ok: false, reason: `${apiKeyEnv} unset` }; }, - async ask(req) { + async ask(/** @type {import("../types.js").DelegationRequest} */ req) { const started = Date.now(); const model = resolveModel(req); const prior = req.threadId && sessions.get(req.threadId); diff --git a/core/registry.js b/core/registry.js index 06f8c4a..91ba049 100644 --- a/core/registry.js +++ b/core/registry.js @@ -1,23 +1,68 @@ "use strict"; /** @typedef {import("./types.js").Provider} Provider */ +/** + * A configured OpenRouter model entry from ~/.claude/claude-delegator/config.json. + * @typedef {Object} OrModel + * @property {string} alias + * @property {string} model + * @property {boolean} [askAll] + * @property {boolean} [consensus] + * @property {(string[]|null)} [experts] + */ + +/** + * The `openrouter` block of the loaded config. + * @typedef {Object} OrConfig + * @property {OrModel[]} [models] + * @property {number} [maxFanout] + */ + +/** + * Per-provider enable flags from the loaded config. + * @typedef {Object} ProviderFlag + * @property {boolean} [enabled] + */ + +/** + * The loaded delegator config (subset consumed by the registry). + * @typedef {Object} RegistryConfig + * @property {Object.} [providers] + * @property {OrConfig} [openrouter] + */ + // Selection semantics mirror server/openrouter/routing.js so the two stay // behaviorally identical. eligibleForExpert: experts null/undefined => all; // [] => none; else must include the expert. +/** + * @param {OrModel} model + * @param {string} expert + * @returns {boolean} + */ function eligibleForExpert(model, expert) { if (model.experts === null || model.experts === undefined) return true; if (model.experts.length === 0) return false; return model.experts.includes(expert); } +/** + * @param {OrConfig} or + * @param {string} expert + * @returns {{selected: OrModel[], omitted: OrModel[]}} + */ function askAllDelegates(or, expert) { - const pool = (or.models || []).filter((m) => m.askAll !== false && eligibleForExpert(m, expert)); - const cap = Number.isInteger(or.maxFanout) && or.maxFanout >= 1 ? or.maxFanout : 3; + const pool = (or.models || []).filter((/** @type {OrModel} */ m) => m.askAll !== false && eligibleForExpert(m, expert)); + const cap = Number.isInteger(or.maxFanout) && /** @type {number} */ (or.maxFanout) >= 1 ? /** @type {number} */ (or.maxFanout) : 3; return { selected: pool.slice(0, cap), omitted: pool.slice(cap) }; } +/** + * @param {OrConfig} or + * @param {string} expert + * @returns {OrModel[]} + */ function consensusDelegates(or, expert) { - return (or.models || []).filter((m) => m.consensus === true && eligibleForExpert(m, expert)); + return (or.models || []).filter((/** @type {OrModel} */ m) => m.consensus === true && eligibleForExpert(m, expert)); } const BUILTINS = ["codex", "gemini", "grok"]; @@ -26,6 +71,11 @@ const BUILTINS = ["codex", "gemini", "grok"]; // alias model and re-labels the result. This is the issue-001 fix: selection // AND dispatch happen inside one server call, so the orchestrator never names // an alias and a disabled one cannot leak from a stale cache. +/** + * @param {Provider} orProvider + * @param {OrModel} delegate + * @returns {Provider} + */ function pinAlias(orProvider, delegate) { return { name: `openrouter:${delegate.alias}`, @@ -40,21 +90,31 @@ function pinAlias(orProvider, delegate) { /** @param {Provider[]} providers */ function makeRegistry(providers) { - const byName = new Map(providers.map((p) => [p.name, p])); + const byName = new Map(providers.map((/** @type {Provider} */ p) => [p.name, p])); + /** + * @param {RegistryConfig} config + * @param {string} name + * @returns {boolean} + */ const enabled = (config, name) => { const p = config && config.providers && config.providers[name]; return !p || p.enabled !== false; // missing = enabled }; - const builtinsFor = (config) => BUILTINS.filter((n) => byName.has(n) && enabled(config, n)).map((n) => byName.get(n)); + /** @param {RegistryConfig} config @returns {Provider[]} */ + const builtinsFor = (config) => + BUILTINS.filter((n) => byName.has(n) && enabled(config, n)).map((n) => /** @type {Provider} */ (byName.get(n))); + /** @param {OrModel[]} delegates @returns {Provider[]} */ const pinDelegates = (delegates) => { const orProvider = byName.get("openrouter"); - return orProvider ? delegates.map((d) => pinAlias(orProvider, d)) : []; + return orProvider ? delegates.map((/** @type {OrModel} */ d) => pinAlias(orProvider, d)) : []; }; return { + /** @param {string} n */ get: (n) => byName.get(n), // Flat provider list ready for askAll(): built-ins + per-alias OR wrappers. + /** @param {{config: RegistryConfig, expert: string}} args */ selectForAskAll({ config, expert }) { const or = (config && config.openrouter) || {}; const { selected, omitted } = askAllDelegates(or, expert); @@ -62,6 +122,7 @@ function makeRegistry(providers) { }, // Uncapped: built-ins + per-alias OR consensus delegates. + /** @param {{config: RegistryConfig, expert: string}} args */ selectForConsensus({ config, expert }) { const or = (config && config.openrouter) || {}; return { providers: [...builtinsFor(config), ...pinDelegates(consensusDelegates(or, expert))] }; diff --git a/server/gemini/index.js b/server/gemini/index.js index fec74ed..d8b78ee 100644 --- a/server/gemini/index.js +++ b/server/gemini/index.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. /** * Claude Delegator - Gemini MCP Bridge diff --git a/server/grok/cache.js b/server/grok/cache.js index 9979fc9..234f225 100644 --- a/server/grok/cache.js +++ b/server/grok/cache.js @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; // crypto + fs helpers are used by buildCacheKey, readCache/writeCache, lookup/store/evict in T2-T7. const path = require("node:path"); diff --git a/server/grok/glob.js b/server/grok/glob.js index 99837e9..0efde39 100644 --- a/server/grok/glob.js +++ b/server/grok/glob.js @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; function rejectBackslashes(pattern) { diff --git a/server/grok/index.js b/server/grok/index.js index 583a8a3..ef6df55 100644 --- a/server/grok/index.js +++ b/server/grok/index.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. /** * Claude Delegator - Grok (xAI) MCP Bridge diff --git a/server/grok/lock.js b/server/grok/lock.js index 7c18f5e..0e7e6ef 100644 --- a/server/grok/lock.js +++ b/server/grok/lock.js @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; const fs = require("node:fs"); const path = require("node:path"); diff --git a/server/openrouter/config.js b/server/openrouter/config.js index 7c5997f..521707d 100644 --- a/server/openrouter/config.js +++ b/server/openrouter/config.js @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; const fs = require("node:fs"); diff --git a/server/openrouter/files.js b/server/openrouter/files.js index 5de3b2a..78dc440 100644 --- a/server/openrouter/files.js +++ b/server/openrouter/files.js @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; const fs = require("node:fs"); diff --git a/server/openrouter/index.js b/server/openrouter/index.js index 2d84c54..c5af123 100644 --- a/server/openrouter/index.js +++ b/server/openrouter/index.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; /** diff --git a/server/openrouter/routing.js b/server/openrouter/routing.js index 414498a..1fd07e2 100644 --- a/server/openrouter/routing.js +++ b/server/openrouter/routing.js @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy bridge; predates the strict typecheck gate (core-only). Opt-in is a separate pass. "use strict"; // NOTE: askAllDelegates/consensusDelegates/eligibleForExpert are the canonical diff --git a/test/core-antigravity.test.js b/test/core-antigravity.test.js index aa1b5f6..08c6708 100644 --- a/test/core-antigravity.test.js +++ b/test/core-antigravity.test.js @@ -5,7 +5,7 @@ const assert = require("node:assert/strict"); const { makeAntigravityProvider } = require("../core/providers/antigravity.js"); const fakeBridge = { - buildAgyArgs: (req) => ["--model", req.model || "auto-gemini-3", req.prompt], + buildAgyArgs: (/** @type {any} */ req) => ["--model", req.model || "auto-gemini-3", req.prompt], runGemini: async () => ({ response: "gemini reply", threadId: "g-1", recovered: false }), classifyGeminiError: () => ({ errorKind: "timeout", retryable: true }), }; @@ -28,7 +28,7 @@ test("AG2: recovered:true is still a success (drain), not an error", async () => test("AG3: thrown runGemini classifies from the real message, not an empty string", async () => { // Mirror the message-keyed branches of the real classifyGeminiError so this // test fails if the adapter ever hardcodes "" again (which silences missing-cli). - const classifyGeminiError = (errMsg, errCode) => { + const classifyGeminiError = (/** @type {any} */ errMsg, /** @type {any} */ errCode) => { const msg = String(errMsg || ""); const lower = msg.toLowerCase(); if (errCode === "timeout") return { errorKind: "timeout", retryable: true }; diff --git a/test/core-codex.test.js b/test/core-codex.test.js index 347864d..8a334a8 100644 --- a/test/core-codex.test.js +++ b/test/core-codex.test.js @@ -28,5 +28,5 @@ test("CX4: a non-zero exit surfaces stdout in .text (diagnostic detail not lost) const r = await p.ask({ prompt: "x" }); assert.equal(r.isError, true); assert.equal(r.errorKind, "unknown"); - assert.equal(r.text, "diagnostic detail from codex"); + assert.equal(/** @type {any} */ (r).text, "diagnostic detail from codex"); }); diff --git a/test/core-grok.test.js b/test/core-grok.test.js index 3280914..e7ec112 100644 --- a/test/core-grok.test.js +++ b/test/core-grok.test.js @@ -4,11 +4,11 @@ const assert = require("node:assert/strict"); const { makeGrokProvider } = require("../core/providers/grok.js"); const fakeBridge = { - buildInitialTurns: (sys, prompt) => [{ role: "system", text: sys }, { role: "user", text: prompt }], - runGrok: async ({ model }) => ({ text: `grok ${model}`, output: null }), // real shape: { text, output } - runWithFiles: async ({ model }) => ({ text: `grok+files ${model}`, output: null, refs: [], ownedIds: [] }), - classifyGrokError: (status) => ({ errorKind: status === 401 ? "auth" : "unknown", retryable: false }), - resolveReasoningEffort: (e) => e || "high", + buildInitialTurns: (/** @type {any} */ sys, /** @type {any} */ prompt) => [{ role: "system", text: sys }, { role: "user", text: prompt }], + runGrok: async (/** @type {any} */ { model }) => ({ text: `grok ${model}`, output: null }), // real shape: { text, output } + runWithFiles: async (/** @type {any} */ { model }) => ({ text: `grok+files ${model}`, output: null, refs: [], ownedIds: [] }), + classifyGrokError: (/** @type {any} */ status) => ({ errorKind: status === 401 ? "auth" : "unknown", retryable: false }), + resolveReasoningEffort: (/** @type {any} */ e) => e || "high", }; test("GK1: capabilities.fileUpload is true, multiTurn is false (not wired through Core)", () => { @@ -30,7 +30,7 @@ test("GK3: ask WITH files routes through runWithFiles", async () => { process.env.XAI_API_KEY = "k"; const p = makeGrokProvider({ bridge: fakeBridge, model: "grok-4.3" }); const r = await p.ask({ prompt: "hi", files: [{ path: "x.js", mode: "auto" }] }); - assert.equal(r.text, "grok+files grok-4.3"); + assert.equal(/** @type {any} */ (r).text, "grok+files grok-4.3"); }); test("GK4: missing XAI_API_KEY -> health false", async () => { diff --git a/test/core-openai-compatible.test.js b/test/core-openai-compatible.test.js index cd3e6e9..e53a696 100644 --- a/test/core-openai-compatible.test.js +++ b/test/core-openai-compatible.test.js @@ -5,10 +5,10 @@ const assert = require("node:assert/strict"); const { makeOpenAICompatibleProvider, MAX_SESSIONS } = require("../core/providers/openai-compatible.js"); const fakeBridge = { - callOpenRouter: async ({ model }) => ({ text: `reply from ${model}` }), - classifyError: (status) => ({ errorKind: status === 401 ? "auth" : "unknown", retryable: false }), - buildMessages: (turns) => turns, - buildInitialTurns: (sys, prompt) => [{ role: "system", text: sys }, { role: "user", text: prompt }], + callOpenRouter: async (/** @type {any} */ { model }) => ({ text: `reply from ${model}` }), + classifyError: (/** @type {any} */ status) => ({ errorKind: status === 401 ? "auth" : "unknown", retryable: false }), + buildMessages: (/** @type {any} */ turns) => turns, + buildInitialTurns: (/** @type {any} */ sys, /** @type {any} */ prompt) => [{ role: "system", text: sys }, { role: "user", text: prompt }], }; test("OC1: ask returns a success DelegationResult with resolved model + threadId", async () => { @@ -44,7 +44,7 @@ test("OC4: the session map is bounded at MAX_SESSIONS across many fresh calls", resolveModel: () => "m", bridge: fakeBridge }); for (let i = 0; i < MAX_SESSIONS + 25; i++) { const r = await p.ask({ prompt: `q${i}` }); // each fresh call -> new threadId - assert.ok(r.threadId); + assert.ok(/** @type {any} */ (r).threadId); } assert.equal(/** @type {any} */ (p).__sessionCount, MAX_SESSIONS); }); diff --git a/test/core-orchestrate.test.js b/test/core-orchestrate.test.js index 36b8386..160d2a1 100644 --- a/test/core-orchestrate.test.js +++ b/test/core-orchestrate.test.js @@ -5,13 +5,13 @@ const assert = require("node:assert/strict"); const { askAll, consensus } = require("../core/orchestrate.js"); /** @typedef {import("../core/types.js").Provider} Provider */ -/** @returns {Provider} */ +/** @param {string} name @param {string} [behavior] @returns {Provider} */ function fakeProvider(name, behavior) { return /** @type {any} */ ({ name, capabilities: { canImplement: false, fileUpload: false, multiTurn: false }, async health() { return { ok: true }; }, - async ask(req) { + async ask(/** @type {any} */ req) { if (behavior === "throw") throw new Error("boom"); return { provider: name, model: "m", text: `${name}:${req.prompt}`, isError: false, ms: 1 }; }, @@ -21,7 +21,7 @@ function fakeProvider(name, behavior) { test("O1: askAll returns one result per provider, in order", async () => { const out = await askAll([fakeProvider("a"), fakeProvider("b")], { prompt: "hi" }); assert.deepEqual(out.map((r) => r.provider), ["a", "b"]); - assert.equal(out[0].text, "a:hi"); + assert.equal(/** @type {any} */ (out[0]).text, "a:hi"); }); test("O2: a thrown provider becomes an isError result, never rejects the batch", async () => { @@ -36,9 +36,9 @@ test("O3: each provider gets an independent copy of the request (zero contaminat /** @type {any[]} */ const seen = []; const a = /** @type {any} */ ({ name: "a", capabilities: {}, async health() { return { ok: true }; }, - async ask(req) { req.prompt += "!"; seen.push(req.prompt); return { provider: "a", model: "m", isError: false, ms: 0 }; } }); + async ask(/** @type {any} */ req) { req.prompt += "!"; seen.push(req.prompt); return { provider: "a", model: "m", isError: false, ms: 0 }; } }); const b = /** @type {any} */ ({ name: "b", capabilities: {}, async health() { return { ok: true }; }, - async ask(req) { seen.push(req.prompt); return { provider: "b", model: "m", isError: false, ms: 0 }; } }); + async ask(/** @type {any} */ req) { seen.push(req.prompt); return { provider: "b", model: "m", isError: false, ms: 0 }; } }); await askAll([a, b], { prompt: "p" }); assert.deepEqual(seen, ["p!", "p"]); // b unaffected by a's mutation }); @@ -46,13 +46,13 @@ test("O3: each provider gets an independent copy of the request (zero contaminat test("C1: consensus fans out then runs ONE arbiter pass over the opinions", async () => { const a = fakeProvider("a"), b = fakeProvider("b"); const arbiter = /** @type {any} */ ({ name: "arb", capabilities: {}, async health() { return { ok: true }; }, - async ask(req) { + async ask(/** @type {any} */ req) { const sawBoth = req.prompt.includes("a:hi") && req.prompt.includes("b:hi"); return { provider: "arb", model: "m", text: `verdict:${sawBoth}`, isError: false, ms: 0 }; } }); const out = await consensus([a, b], { prompt: "hi" }, { arbiter }); assert.equal(out.opinions.length, 2); - assert.equal(out.verdict && out.verdict.text, "verdict:true"); // arbiter received both opinions in its prompt + assert.equal(out.verdict && /** @type {any} */ (out.verdict).text, "verdict:true"); // arbiter received both opinions in its prompt }); test("C2: all-failed short-circuits with no arbiter call", async () => { diff --git a/test/core-provider.test.js b/test/core-provider.test.js index 4484100..a9d7c41 100644 --- a/test/core-provider.test.js +++ b/test/core-provider.test.js @@ -5,7 +5,7 @@ const assert = require("node:assert/strict"); const { toErrorResult, OPINION_SCHEMA, validateOpinion } = require("../core/provider.js"); test("P1: toErrorResult normalizes a thrown error via the bridge classifier", () => { - const classify = (status) => ({ errorKind: status === 429 ? "rate-limit" : "unknown", retryable: status === 429 }); + const classify = (/** @type {any} */ status) => ({ errorKind: status === 429 ? "rate-limit" : "unknown", retryable: status === 429 }); const r = toErrorResult("openrouter", "x/y", Date.now() - 5, { status: 429 }, classify); assert.equal(r.provider, "openrouter"); assert.equal(r.model, "x/y"); @@ -24,5 +24,5 @@ test("P3: validateOpinion accepts a minimal opinion, rejects a missing field", ( assert.equal(validateOpinion({ recommendation: "ship it", confidence: "high" }).ok, true); const bad = validateOpinion({ recommendation: "ship it" }); assert.equal(bad.ok, false); - assert.match(bad.reason, /confidence/); + assert.match(String(bad.reason), /confidence/); }); diff --git a/test/core-registry.test.js b/test/core-registry.test.js index 577db6f..4f47717 100644 --- a/test/core-registry.test.js +++ b/test/core-registry.test.js @@ -3,7 +3,7 @@ const { test } = require("node:test"); const assert = require("node:assert/strict"); const { makeRegistry } = require("../core/registry.js"); -/** @returns {import("../core/types.js").Provider} */ +/** @param {string} name @returns {import("../core/types.js").Provider} */ function prov(name) { return /** @type {any} */ ({ name, capabilities: {}, async health() { return { ok: true }; }, async ask() { return { provider: name, model: "m", isError: false, ms: 0 }; } }); } @@ -22,7 +22,7 @@ const config = { test("G1: get returns a registered provider by name", () => { const reg = makeRegistry([prov("codex"), prov("grok")]); - assert.equal(reg.get("grok").name, "grok"); + assert.equal(/** @type {any} */ (reg.get("grok")).name, "grok"); assert.equal(reg.get("nope"), undefined); }); @@ -43,7 +43,7 @@ test("G3: a disabled (askAll:false) OR model is never in the fan-out - issue 001 test("G4: a per-alias OR wrapper injects the alias model into ask() and renames provider", async () => { let gotModel; const orp = /** @type {any} */ ({ name: "openrouter", capabilities: {}, async health() { return { ok: true }; }, - async ask(req) { gotModel = req.model; return { provider: "openrouter", model: req.model, isError: false, ms: 0 }; } }); + async ask(/** @type {any} */ req) { gotModel = req.model; return { provider: "openrouter", model: req.model, isError: false, ms: 0 }; } }); const reg = makeRegistry([orp]); const { providers } = reg.selectForAskAll({ config, expert: "architect" }); const wrapped = /** @type {any} */ (providers.find((p) => p.name === "openrouter:all-on")); diff --git a/tsconfig.json b/tsconfig.json index 35d6413..fc435d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,7 @@ "allowJs": true, "checkJs": true, "noEmit": true, - "strict": false, - "noImplicitAny": false, + "strict": true, "skipLibCheck": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, From e4a8e6ee36db440215604a38228414e767ec46cc Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:41:42 +0200 Subject: [PATCH 13/19] refactor(core): DelegationResult as Success|Error discriminated union --- core/orchestrate.js | 8 +++++--- core/provider.js | 8 ++++---- core/providers/antigravity.js | 4 +++- core/providers/codex.js | 3 ++- core/providers/grok.js | 2 +- core/types.js | 20 +++++++++++++++----- test/core-codex.test.js | 5 +++-- test/core-provider.test.js | 2 +- 8 files changed, 34 insertions(+), 18 deletions(-) diff --git a/core/orchestrate.js b/core/orchestrate.js index f76aab8..ee4d161 100644 --- a/core/orchestrate.js +++ b/core/orchestrate.js @@ -2,6 +2,7 @@ /** @typedef {import("./types.js").Provider} Provider */ /** @typedef {import("./types.js").DelegationRequest} DelegationRequest */ /** @typedef {import("./types.js").DelegationResult} DelegationResult */ +/** @typedef {import("./types.js").DelegationSuccess} DelegationSuccess */ /** * Fan out ONE request to N providers concurrently. The whole serialization fix: @@ -26,7 +27,6 @@ async function askAll(providers, req) { isError: true, errorKind: "unknown", retryable: false, - text: undefined, message: String((s.reason && s.reason.message) || s.reason || "rejected"), ms: 0, } @@ -46,7 +46,7 @@ async function askOne(provider, req) { /** * Assemble the arbiter prompt from independent opinions for blind cross-review. * @param {string} question - * @param {DelegationResult[]} opinions // successful opinions only + * @param {DelegationSuccess[]} opinions // successful opinions only (text guaranteed) * @returns {string} */ function buildArbiterPrompt(question, opinions) { @@ -75,7 +75,9 @@ function buildArbiterPrompt(question, opinions) { */ async function consensus(providers, req, opts = {}) { const opinions = await askAll(providers, req); - const ok = opinions.filter((o) => !o.isError && o.text); + // The union guarantees `text` on the success branch, so `!o.isError` alone + // narrows each survivor to DelegationSuccess - no `&& o.text` guard needed. + const ok = /** @type {DelegationSuccess[]} */ (opinions.filter((o) => !o.isError)); if (!ok.length) return { opinions, verdict: null, error: "all-providers-failed" }; const arbiter = opts.arbiter || providers[0]; if (!arbiter) return { opinions, verdict: null, error: "no-arbiter" }; diff --git a/core/provider.js b/core/provider.js index 65aa85e..6f86330 100644 --- a/core/provider.js +++ b/core/provider.js @@ -1,19 +1,19 @@ "use strict"; -/** @typedef {import("./types.js").DelegationResult} DelegationResult */ +/** @typedef {import("./types.js").DelegationError} DelegationError */ /** - * Normalize a thrown bridge error into a DelegationResult using that bridge's + * Normalize a thrown bridge error into a DelegationError using that bridge's * own classifier, so behavior matches the standalone bridge exactly. * @param {string} name * @param {string} model * @param {number} started // Date.now() at call start * @param {{status?:number, code?:string}} err * @param {(status?:number, code?:string) => {errorKind:string, retryable:boolean}} classify - * @returns {DelegationResult} + * @returns {DelegationError} */ function toErrorResult(name, model, started, err, classify) { const { errorKind, retryable } = classify(err && err.status, err && err.code); - return { provider: name, model, isError: true, errorKind, retryable, text: undefined, ms: Date.now() - started }; + return { provider: name, model, isError: true, errorKind, retryable, ms: Date.now() - started }; } // Structured opinion schema shared across providers (Core minimum). diff --git a/core/providers/antigravity.js b/core/providers/antigravity.js index 5e5927e..1143bac 100644 --- a/core/providers/antigravity.js +++ b/core/providers/antigravity.js @@ -35,7 +35,9 @@ function makeAntigravityProvider(opts = {}) { try { // runGemini(args, cwd, timeoutMs, recoveryGraceMs). recovered:true => normal success. const out = await bridge.runGemini(args, req.cwd, req.timeoutMs, undefined); - return { provider: "gemini", model, text: out.response, threadId: out.threadId, isError: false, ms: Date.now() - started }; + // out.response can be undefined on a degenerate clean run; coerce to "" + // so the DelegationSuccess.text contract (string, not string|undefined) holds. + return { provider: "gemini", model, text: out.response || "", threadId: out.threadId, isError: false, ms: Date.now() - started }; } catch (e) { // classifyGeminiError(errMsg, errCode): the missing-cli and upstream-abort // branches key off the message, so pass the real caught message - not "". diff --git a/core/providers/codex.js b/core/providers/codex.js index 355c2ea..a687b31 100644 --- a/core/providers/codex.js +++ b/core/providers/codex.js @@ -69,7 +69,8 @@ function makeCodexProvider(opts = {}) { isError: true, errorKind, retryable, - text: (stdout && stdout.trim()) || undefined, // keep stdout-borne error detail + // Error results carry no text; surface stdout/stderr diagnostics in message. + message: (stdout && stdout.trim()) || stderr || undefined, ms: Date.now() - started, }; }, diff --git a/core/providers/grok.js b/core/providers/grok.js index 552cfc3..e6da653 100644 --- a/core/providers/grok.js +++ b/core/providers/grok.js @@ -40,7 +40,7 @@ function makeGrokProvider(opts = {}) { turns: bridge.buildInitialTurns(req.developerInstructions, req.prompt, []), model, apiKey, apiBase, reasoningEffort, timeoutMs: req.timeoutMs, }); - return { provider: "grok", model, text: out.text, isError: false, ms: Date.now() - started }; + return { provider: "grok", model, text: out.text || "", isError: false, ms: Date.now() - started }; } catch (e) { return toErrorResult("grok", model, started, /** @type {any} */ (e), bridge.classifyGrokError); } diff --git a/core/types.js b/core/types.js index ea773f2..b85ecd0 100644 --- a/core/types.js +++ b/core/types.js @@ -25,18 +25,28 @@ */ /** - * @typedef {Object} DelegationResult + * @typedef {Object} DelegationSuccess + * @property {false} isError * @property {string} provider * @property {string} model - * @property {string} [text] + * @property {string} text * @property {string} [threadId] - * @property {boolean} isError - * @property {string} [errorKind] + * @property {number} ms + */ + +/** + * @typedef {Object} DelegationError + * @property {true} isError + * @property {string} provider + * @property {string} model + * @property {string} errorKind + * @property {boolean} retryable * @property {string} [message] - * @property {boolean} [retryable] * @property {number} ms */ +/** @typedef {DelegationSuccess | DelegationError} DelegationResult */ + /** * @typedef {Object} ProviderCapabilities * @property {boolean} canImplement diff --git a/test/core-codex.test.js b/test/core-codex.test.js index 8a334a8..6a1d2b2 100644 --- a/test/core-codex.test.js +++ b/test/core-codex.test.js @@ -23,10 +23,11 @@ test("CX3: capabilities.canImplement true (Core still calls advisory only)", () assert.equal(makeCodexProvider({ run: async () => ({ code: 0, stdout: "", stderr: "" }) }).capabilities.canImplement, true); }); -test("CX4: a non-zero exit surfaces stdout in .text (diagnostic detail not lost)", async () => { +test("CX4: a non-zero exit surfaces stdout in .message (diagnostic detail not lost; error has no text)", async () => { const p = makeCodexProvider({ run: async () => ({ code: 1, stdout: "diagnostic detail from codex", stderr: "boom" }) }); const r = await p.ask({ prompt: "x" }); assert.equal(r.isError, true); assert.equal(r.errorKind, "unknown"); - assert.equal(/** @type {any} */ (r).text, "diagnostic detail from codex"); + assert.equal("text" in r, false); // error results carry no text key + assert.equal(/** @type {any} */ (r).message, "diagnostic detail from codex"); }); diff --git a/test/core-provider.test.js b/test/core-provider.test.js index a9d7c41..6cc1f6c 100644 --- a/test/core-provider.test.js +++ b/test/core-provider.test.js @@ -12,7 +12,7 @@ test("P1: toErrorResult normalizes a thrown error via the bridge classifier", () assert.equal(r.isError, true); assert.equal(r.errorKind, "rate-limit"); assert.equal(r.retryable, true); - assert.equal(r.text, undefined); + assert.equal("text" in r, false); // error results carry no text key assert.ok(r.ms >= 0); }); From bee552fc0339c239f10e7fef860717deb64fe610 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 15:50:38 +0200 Subject: [PATCH 14/19] feat(mcp): stdio server over deliberation-core (ask-* + ask-all + consensus + experts) --- server/mcp/index.js | 164 ++++++++++++++++++++++++++++++++++++++++ server/mcp/package.json | 9 +++ test/mcp-server.test.js | 60 +++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 server/mcp/index.js create mode 100644 server/mcp/package.json create mode 100644 test/mcp-server.test.js diff --git a/server/mcp/index.js b/server/mcp/index.js new file mode 100644 index 0000000..10e662c --- /dev/null +++ b/server/mcp/index.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node +"use strict"; +/** Minimal stdio JSON-RPC MCP server over deliberation-core. Zero deps. */ +/** @typedef {import("../../core/types.js").Provider} Provider */ +/** @typedef {import("../../core/types.js").DelegationRequest} DelegationRequest */ + +const { makeRegistry } = require("../../core/registry.js"); +const { askAll, askOne, consensus } = require("../../core/orchestrate.js"); + +const ADVISORY = { readOnlyHint: true }; +/** @type {Record} */ +const ASK_PROVIDER = { "ask-gpt": "codex", "ask-gemini": "gemini", "ask-grok": "grok", "ask-openrouter": "openrouter" }; +const EXPERTS = ["architect", "plan-reviewer", "scope-analyst", "code-reviewer", "security-analyst", "researcher", "debugger"]; + +function inputSchema() { + return { + type: "object", + required: ["prompt"], + properties: { + prompt: { type: "string" }, + expert: { type: "string" }, + developerInstructions: { type: "string" }, + cwd: { type: "string" }, + reasoningEffort: { type: "string", enum: ["low", "medium", "high", "none"] }, + }, + }; +} + +function toolList() { + const tools = [ + { name: "ask-all", description: "Fan out one question to all enabled providers in parallel (advisory).", inputSchema: inputSchema(), annotations: ADVISORY }, + { name: "consensus", description: "Fan out then run one arbiter pass for a synthesized verdict (advisory).", inputSchema: inputSchema(), annotations: ADVISORY }, + ]; + for (const t of Object.keys(ASK_PROVIDER)) { + tools.push({ name: t, description: `Single-provider second opinion via ${ASK_PROVIDER[t]} (advisory).`, inputSchema: inputSchema(), annotations: ADVISORY }); + } + for (const e of EXPERTS) { + tools.push({ name: e, description: `Direct ${e} expert (advisory).`, inputSchema: inputSchema(), annotations: ADVISORY }); + } + return tools; +} + +/** + * @param {Object} deps + * @param {Provider[]} deps.providers + * @param {() => any} deps.getConfig + */ +function buildServer({ providers, getConfig }) { + const registry = makeRegistry(providers); + + /** + * @param {string} name + * @param {any} args // untrusted JSON-RPC tool arguments + */ + async function call(name, args) { + /** @type {DelegationRequest} */ + const req = { + prompt: args.prompt, + expert: args.expert, + developerInstructions: args.developerInstructions, + cwd: args.cwd, + reasoningEffort: args.reasoningEffort, + files: args.files, + }; + if (name === "ask-all") { + // selectForAskAll returns a FLAT provider list: enabled built-ins + per-alias OR wrappers. + const { providers: selected, omitted } = registry.selectForAskAll({ config: getConfig(), expert: req.expert || "" }); + const results = await askAll(selected, req); + return { content: [{ type: "text", text: JSON.stringify({ results, omitted }) }] }; + } + if (name === "consensus") { + // selectForConsensus returns a FLAT, uncapped provider list. consensus() fans out + // then runs ONE arbiter pass (default arbiter = providers[0]). + const { providers: selected } = registry.selectForConsensus({ config: getConfig(), expert: req.expert || "" }); + const out = await consensus(selected, req); + return { content: [{ type: "text", text: JSON.stringify(out) }] }; + } + if (ASK_PROVIDER[name]) { + const p = registry.get(ASK_PROVIDER[name]); + if (!p) return { content: [{ type: "text", text: JSON.stringify({ error: `provider ${ASK_PROVIDER[name]} not registered` }) }] }; + const result = await askOne(p, req); + return { content: [{ type: "text", text: JSON.stringify({ result }) }] }; + } + if (EXPERTS.includes(name)) { + const { providers: selected } = registry.selectForAskAll({ config: getConfig(), expert: name }); + const results = await askAll(selected, { ...req, expert: name }); + return { content: [{ type: "text", text: JSON.stringify({ results }) }] }; + } + throw new Error(`unknown tool: ${name}`); + } + + /** @param {any} msg */ + async function handle(msg) { + try { + if (msg.method === "initialize") { + return { jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "deliberation-mcp", version: "0.1.0" } } }; + } + if (msg.method === "tools/list") return { jsonrpc: "2.0", id: msg.id, result: { tools: toolList() } }; + if (msg.method === "tools/call") { + const result = await call(msg.params.name, msg.params.arguments || {}); + return { jsonrpc: "2.0", id: msg.id, result }; + } + return { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `method not found: ${msg.method}` } }; + } catch (e) { + const err = /** @type {any} */ (e); + return { jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: String((err && err.message) || err) } }; + } + } + + return { handle, toolList }; +} + +function startStdio() { + const { makeOpenAICompatibleProvider } = require("../../core/providers/openai-compatible.js"); + const { makeGrokProvider } = require("../../core/providers/grok.js"); + const { makeAntigravityProvider } = require("../../core/providers/antigravity.js"); + const { makeCodexProvider } = require("../../core/providers/codex.js"); + const configMod = /** @type {any} */ (require("../openrouter/config.js")); + const { makeConfigReader, DEFAULT_API_BASE, DEFAULT_API_KEY_ENV } = configMod; + const path = require("node:path"); + const os = require("node:os"); + const reader = makeConfigReader(path.join(os.homedir(), ".claude", "claude-delegator", "config.json")); + /** @returns {any} */ + const getConfig = () => (reader.get().resolved || { providers: {}, openrouter: {} }); + + const initialOr = (getConfig().openrouter) || {}; + /** @type {Provider[]} */ + const providers = [ + makeCodexProvider({}), + makeAntigravityProvider({}), + makeGrokProvider({}), + makeOpenAICompatibleProvider({ + name: "openrouter", + apiBase: initialOr.apiBase || DEFAULT_API_BASE, + apiKeyEnv: DEFAULT_API_KEY_ENV, + resolveModel: (req) => req.model || (getConfig().openrouter && getConfig().openrouter.defaultModel) || "", + }), + ]; + const srv = buildServer({ providers, getConfig }); + + if (typeof globalThis.fetch !== "function") { + console.error("deliberation-mcp requires Node 18+ (global fetch unavailable)."); + process.exit(1); + } + + let buffer = ""; + process.stdin.on("data", async (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const l = line.trim(); + if (!l) continue; + let msg; + try { msg = JSON.parse(l); } catch { continue; } + const res = await srv.handle(msg); + if (msg.id !== undefined) process.stdout.write(JSON.stringify(res) + "\n"); + } + }); +} + +if (require.main === module) startStdio(); + +module.exports = { buildServer, toolList }; diff --git a/server/mcp/package.json b/server/mcp/package.json new file mode 100644 index 0000000..905377e --- /dev/null +++ b/server/mcp/package.json @@ -0,0 +1,9 @@ +{ + "name": "@antonbabenko/deliberation-mcp", + "version": "0.1.0", + "description": "Deliberation for Claude Code and any MCP host - GPT, Gemini, Grok, and OpenRouter expert subagents.", + "bin": { "deliberation-mcp": "./index.js" }, + "main": "./index.js", + "files": ["index.js"], + "license": "MIT" +} diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js new file mode 100644 index 0000000..a75cc37 --- /dev/null +++ b/test/mcp-server.test.js @@ -0,0 +1,60 @@ +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { buildServer } = require("../server/mcp/index.js"); + +function fakeProvider(name) { + return { + name, + capabilities: { canImplement: false, fileUpload: false, multiTurn: false }, + async health() { return { ok: true }; }, + async ask(req) { return { provider: name, model: "m", text: `${name}:${req.prompt}`, isError: false, ms: 1 }; }, + }; +} +const config = { providers: {}, openrouter: { maxFanout: 3, models: [] } }; +const orConfig = { providers: {}, openrouter: { maxFanout: 3, models: [ + { alias: "on", model: "x/on", experts: null, askAll: true, consensus: true }, + { alias: "off", model: "x/off", experts: null, askAll: false, consensus: true }, +] } }; + +test("M1: tools/list includes ask-all with readOnlyHint", async () => { + const srv = buildServer({ providers: [fakeProvider("codex"), fakeProvider("grok")], getConfig: () => config }); + const res = await srv.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + const askAllTool = res.result.tools.find((t) => t.name === "ask-all"); + assert.ok(askAllTool); + assert.equal(askAllTool.annotations.readOnlyHint, true); +}); + +test("M2: tools/call ask-all fans out to all enabled built-ins", async () => { + const srv = buildServer({ providers: [fakeProvider("codex"), fakeProvider("grok")], getConfig: () => config }); + const res = await srv.handle({ jsonrpc: "2.0", id: 2, method: "tools/call", + params: { name: "ask-all", arguments: { prompt: "hello", expert: "architect" } } }); + const payload = JSON.parse(res.result.content[0].text); + assert.deepEqual(payload.results.map((r) => r.provider).sort(), ["codex", "grok"]); +}); + +test("M3: ask-gpt routes to codex only", async () => { + const srv = buildServer({ providers: [fakeProvider("codex"), fakeProvider("grok")], getConfig: () => config }); + const res = await srv.handle({ jsonrpc: "2.0", id: 3, method: "tools/call", + params: { name: "ask-gpt", arguments: { prompt: "hi" } } }); + const payload = JSON.parse(res.result.content[0].text); + assert.equal(payload.result.provider, "codex"); +}); + +test("M4: consensus runs fan-out + one arbiter pass and returns opinions + verdict", async () => { + const srv = buildServer({ providers: [fakeProvider("codex"), fakeProvider("grok")], getConfig: () => config }); + const res = await srv.handle({ jsonrpc: "2.0", id: 4, method: "tools/call", + params: { name: "consensus", arguments: { prompt: "x", expert: "architect" } } }); + const payload = JSON.parse(res.result.content[0].text); + assert.equal(payload.opinions.length, 2); + assert.ok(payload.verdict); // arbiter (first provider) produced a verdict +}); + +test("M5: ask-all expands OR per-alias and never dispatches askAll:false (issue 001 closed)", async () => { + const srv = buildServer({ providers: [fakeProvider("codex"), fakeProvider("openrouter")], getConfig: () => orConfig }); + const res = await srv.handle({ jsonrpc: "2.0", id: 5, method: "tools/call", + params: { name: "ask-all", arguments: { prompt: "hi", expert: "architect" } } }); + const provs = JSON.parse(res.result.content[0].text).results.map((r) => r.provider).sort(); + assert.deepEqual(provs, ["codex", "openrouter:on"]); // off excluded server-side + assert.equal(provs.includes("openrouter:off"), false); +}); From 0800cef0d7d8f7a9066e4777c0dfa1c802b7a6a3 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 16:08:52 +0200 Subject: [PATCH 15/19] refactor(plugin): /ask-all and /consensus dispatch via deliberation-mcp (closes issue 001) --- .claude-plugin/mcp.json | 29 +++++ commands/ask-all.md | 233 ++++++++++++++-------------------------- commands/consensus.md | 155 +++++++------------------- 3 files changed, 149 insertions(+), 268 deletions(-) create mode 100644 .claude-plugin/mcp.json diff --git a/.claude-plugin/mcp.json b/.claude-plugin/mcp.json new file mode 100644 index 0000000..fac3b30 --- /dev/null +++ b/.claude-plugin/mcp.json @@ -0,0 +1,29 @@ +{ + "mcpServers": { + "codex": { + "command": "codex", + "args": ["mcp-server"], + "type": "stdio" + }, + "gemini": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server/gemini/index.js"], + "type": "stdio" + }, + "grok": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server/grok/index.js"], + "type": "stdio" + }, + "openrouter": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server/openrouter/index.js"], + "type": "stdio" + }, + "deliberation": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server/mcp/index.js"], + "type": "stdio" + } + } +} diff --git a/commands/ask-all.md b/commands/ask-all.md index 741ef1b..6b465f6 100644 --- a/commands/ask-all.md +++ b/commands/ask-all.md @@ -1,13 +1,13 @@ --- name: ask-all description: Ask GPT, Gemini, Grok, and any configured OpenRouter models in parallel for independent second opinions, then synthesize and compare. Zero cross-contamination. -allowed-tools: mcp__codex__codex, mcp__gemini__gemini, mcp__grok__grok, mcp__openrouter__openrouter, mcp__openrouter__openrouter-list, Read, Bash +allowed-tools: mcp__deliberation__ask-all, Read, Bash timeout: 300000 --- # Ask All (GPT + Gemini + Grok + OpenRouter) -Parallel dispatch to GPT (Codex), Gemini, and Grok (xAI) for independent second opinions on the same question. Three fresh threads, none sees the others' output. Final synthesis compares verdicts and flags disagreement. Grok is advisory-only (HTTP API; it reads attached files via `files` but cannot edit), so all three run `read-only`. +Parallel dispatch to GPT (Codex), Gemini, Grok (xAI), and any eligible OpenRouter aliases for independent second opinions on the same question. The server fans out in ONE call - each delegate runs on a fresh advisory thread and none sees the others' output. Final synthesis compares verdicts and flags disagreement. All delegates are advisory (`read-only`); selection and dispatch happen server-side, so the command never names an alias. ## Input @@ -15,185 +15,114 @@ User question or topic: $ARGUMENTS ## Workflow -0. **Prep - one parallel message (concurrent prep, single dispatch).** Identify the expert role first (step 1 below is *reasoning* on `$ARGUMENTS`, no tool call), then fire ALL independent prep reads in ONE message as parallel tool blocks - do NOT serialize them across turns: + + +0. **Prep - identify the expert and load its prompt.** Identify the expert role first (step 1 below is *reasoning* on `$ARGUMENTS`, no tool call), then read the expert prompt: - `Glob` `~/.claude/plugins/cache/*/claude-delegator/*/prompts/[expert].md` (expert prompt) - - `Read` `~/.claude/claude-delegator/config.json` (built-in `enabled` flags + OpenRouter config) - - `Read` `~/.codex/config.toml` (Codex `model` + `model_reasoning_effort`; a `-c model=` / `-c model_reasoning_effort=` MCP registration override still wins; missing key = `default`) - - `Read` `~/.gemini/settings.json` (`model.name`, default `auto-gemini-3`; effort always `n/a`) - - `Bash` `echo "$GROK_DEFAULT_MODEL" "$GROK_REASONING_EFFORT"` (Grok model/effort; unset -> `grok-4.3` / `high`) - - `mcp__openrouter__openrouter-list` (OpenRouter delegate set + per-model resolved effort) - Steps 2, 4, and 5b below CONSUME these cached results - they MUST NOT issue their own reads. Any source that cannot be read prints `unknown` in the status block (never invent a value); it does NOT trigger a follow-up serial read. The only allowed serial step in the preamble is the `invalidModels` `AskUserQuestion` gate in 5b (rare path). + Delegate selection - which built-ins are enabled, which OpenRouter aliases are eligible, the fanout cap - is resolved server-side by `mcp__deliberation__ask-all`. The command does not read `config.json`, list OpenRouter aliases, or pre-read provider model config. The exact dispatched delegates, their models, and any fanout-capped omissions come back in the tool response, so the status block (step 4) is built from that response, not from pre-read sources. -1. **Identify expert** - match `$ARGUMENTS` against trigger patterns in `~/.claude/rules/delegator/triggers.md`. Use the **same expert role** for all three providers so verdicts are comparable. Default to Architect if unclear. +1. **Identify expert** - match `$ARGUMENTS` against trigger patterns in `~/.claude/rules/delegator/triggers.md`. The server applies the **same expert role** to every delegate so verdicts are comparable. Default to Architect if unclear. 2. **Read expert prompt** via this resolution sequence: 1. Glob `~/.claude/plugins/cache/*/claude-delegator/*/prompts/[expert].md`. Pick the match with the highest semver version segment (the segment immediately after `claude-delegator/`, parsed as semver - not lexical string compare). 2. If no match, look up the inlined fallback under the heading `## Inlined fallback - [Expert]` in this command file (see end of this file). 3. If neither found, abort with: `Error: claude-delegator plugin cache missing for expert "[Expert]". Run /plugin install claude-delegator or /reload-plugins.` - Same prompt injected into all three providers. + Pass the loaded prompt as the `developerInstructions` argument so the server injects the same expert prompt into every delegate. -3. **Build 7-section delegation prompt** per `~/.claude/rules/delegator/delegation-format.md`. **Identical prompt** sent to all three providers - no provider-specific framing. Include: +3. **Build 7-section delegation prompt** per `~/.claude/rules/delegator/delegation-format.md`. **Identical prompt** sent to every delegate - the server forwards the same `prompt` to all of them, so there is no provider-specific framing. This is the `prompt` argument for the tool call. Include: - Verbatim user question from `$ARGUMENTS` - Relevant code snippets / file paths from current conversation context - Any specific constraints user has mentioned this session -4. **Print status block** (after building the delegate set in 5b, immediately before the dispatch in step 6): one line per delegate that is actually being dispatched, showing the exact model and the reasoning effort each will run with. Do not print a generic single line, and do not list providers that are skipped or fanout-capped. - - ``` - Working in parallel (typical 30-60s): - - Codex (GPT) gpt-5.5 reasoning: high - - Gemini auto-gemini-3 reasoning: n/a - - Grok (xAI) grok-4.3 reasoning: high - - OpenRouter / deepseek-v4-pro deepseek/deepseek-v4-pro reasoning: medium - - OpenRouter / kimi-k2-thinking moonshotai/kimi-k2-thinking reasoning: high - ``` + **Repo-wide context (file-blind delegates):** the server fans out to advisory + providers, some of which (notably Grok and OpenRouter aliases) see ONLY what the + `prompt` names - they do not walk the filesystem. For any open-ended, repo-wide + question ("improve this repo", "audit this code", "what are the tradeoffs in our + architecture"), embed the orientation context directly in the `prompt`: - Resolve each field from its real source - never invent a value (print `unknown` if a source cannot be read). All these sources are already fetched in the Step 0 prep message; read them from those cached results, do NOT issue new reads here: - - **Codex**: model + effort from `~/.codex/config.toml` (`model`, `model_reasoning_effort`), or a `-c model=` / `-c model_reasoning_effort=` override on the MCP registration. Label a missing key `default`. - - **Gemini**: model from `~/.gemini/settings.json` (`model.name`, default `auto-gemini-3`). Reasoning effort is always `n/a` - agy exposes no reasoning knob. - - **Grok**: model = `$GROK_DEFAULT_MODEL` else `grok-4.3`; effort = `$GROK_REASONING_EFFORT` else `high`. - - **OpenRouter**: model and `reasoning_effort` come straight from each delegate in the `mcp__openrouter__openrouter-list` result (the list already resolves per-model override > `defaults` > `null`). A `null` effort prints as `default`. - -5. **Set cwd** (Gemini path) - use `process.cwd()` as the MCP `cwd`; agy print mode needs no folder-trust pre-check. Grok and Codex have no trusted-directory concept either. - -5b. **Build the active delegate set from config** (use the `config.json` and `openrouter-list` results already fetched in Step 0 - do not re-read here, except the `openrouter-list` RE-CALL on the Fix & proceed branch below)**:** - - From the Step 0 `~/.claude/claude-delegator/config.json` read (if present). For each built-in - (`codex`/`gemini`/`grok`), include it ONLY if `providers..enabled` is not - `false` (missing = enabled) AND its MCP tool is available; otherwise skip it. - - Call `mcp__openrouter__openrouter-list`. If unavailable or its `error` is set (a hard - config failure - bad JSON, schema, version, or maxFanout), the OpenRouter delegate set - is EMPTY (use the Step 0 `mcp__openrouter__openrouter-list` result; only RE-CALL it on the Fix & proceed branch below). Otherwise the returned `delegates` are the valid models; `invalidModels` (if - present and non-empty) are entries the bridge skipped because of a per-entry problem - (each `{ index, alias, reason, suggestedAlias? }`). - - **If `invalidModels` is non-empty, do NOT silently drop them.** PRINT a short report - - one line per entry: `alias|index` + `reason` + `-> suggestedAlias` when present - then - ask with `AskUserQuestion` (first option is the pre-selected default): - 1. **Fix & proceed (Recommended)** - for each invalid entry that has a `suggestedAlias`, - `Edit` `~/.claude/claude-delegator/config.json` to apply it (rename the `alias`); - drop (and note) any entry with no `suggestedAlias` since it cannot be auto-repaired - (e.g. missing `model`, unknown expert). Then re-call `openrouter-list` (again with - `mode:"ask-all"` + `expert`) and use the resulting `selected` set. - 2. **Run valid only** - leave `config.json` untouched; use the returned `selected` - as-is and note the skipped `invalidModels` entries in the synthesis. - 3. **Skip all OpenRouter** - empty OpenRouter set for this run. - - **OpenRouter delegate set = the `selected` array returned by `openrouter-list`** (the - bridge already applied `askAll != false`, expert eligibility, config order, and the - `maxFanout` cap via its canonical routing). Do NOT re-derive selection here. Report the - returned `omitted` entries as the cap note in the final synthesis (no silent truncation). - -6. **Parallel dispatch** - fire ALL selected provider calls in a **single message** (the enabled built-ins plus each selected OpenRouter delegate from 5b), as **parallel tool blocks** so they run concurrently: + 1. Pick 2-6 high-signal files: project `CLAUDE.md` / `AGENTS.md` / `README.md`, + the top-level entrypoint (`main.tf`, `package.json`, `app.py`, `Cargo.toml`, + `pyproject.toml`, etc.), and the module the question targets. + 2. Paste or summarize the load-bearing parts of those files into the `prompt`, and + state which files the evidence came from ("Context from CLAUDE.md, main.tf, + app/app.py - reason from these."). + 3. Fallback when `CLAUDE.md`/`AGENTS.md` is absent: substitute `README.md`, then + the top-level entrypoint inferred from project type. + + This keeps file-blind delegates reasoning from real source instead of a bare + description. If you skip it for a whole-repo question, NOTE the asymmetry in the + synthesis ("file-blind delegates answered without repo source; discount their + specificity"). + +4. **Set cwd** - use `process.cwd()` as the MCP `cwd`; the server resolves each provider's working directory from it. + +5. **Single dispatch** - make ONE call to `mcp__deliberation__ask-all`. The server selects + AND dispatches the active delegate set in parallel: the enabled built-ins (Codex / Gemini / + Grok) plus every eligible OpenRouter alias, applying `askAll` eligibility, expert + eligibility, and the fanout cap server-side. The command NEVER names an OpenRouter alias and + never re-derives selection. This closes the disabled-alias dispatch gap structurally: a + disabled or stale alias cannot be dispatched because the orchestrator has no alias to pass + and the server owns validation. ``` - mcp__codex__codex({ + mcp__deliberation__ask-all({ prompt: "[identical 7-section prompt]", - "developer-instructions": "[expert prompt]", - sandbox: "read-only", + expert: "[chosen expert]", cwd: "[cwd]" }) + ``` - mcp__gemini__gemini({ - prompt: "[identical 7-section prompt]", - "developer-instructions": "[expert prompt]", - sandbox: "read-only", - model: "auto-gemini-3", - cwd: "[cwd]" - }) + The tool returns `{ results: DelegationResult[], omitted: [...] }`. Each result is + `{ provider, model, text?, isError, errorKind?, ms }` where `provider` is `codex`, + `gemini`, `grok`, or `openrouter:`. `omitted` lists aliases the server dropped to + honor the fanout cap - report it as the cap note in the synthesis, never silent truncation. - mcp__grok__grok({ - prompt: "[identical 7-section prompt]", - "developer-instructions": "[expert prompt]", - sandbox: "read-only", - cwd: "[repo root - required when attaching files by path]", - files: [{ path: "path/relative/to/cwd" }] // attach referenced local files by default - }) - ``` - For EACH selected OpenRouter delegate, add one more parallel tool block: - ``` - mcp__openrouter__openrouter({ - prompt: "[identical 7-section prompt]", - "developer-instructions": "[expert prompt]", - alias: "[delegate alias]", - sandbox: "read-only", - cwd: "[repo root]", - files: [ /* SAME orientation bundle passed to Grok, e.g. {path|dir, mode:"auto"} */ ] - }) - ``` - OpenRouter delegates obey the same "provider failure does not kill the command" rule; an - errored delegate renders as `OpenRouter: bottom line: UNAVAILABLE (...)`. +6. **Print status block** - after `mcp__deliberation__ask-all` returns and before the + synthesis, print one line per delegate the server actually dispatched. Source every line + from the returned `results` array (one line per result): the `provider` label and its + `model`. Reasoning effort is not carried in the result, so print `n/a` in that column. Do + not print a generic single line, and do not list delegates the server skipped. - **Provider failure does not kill the command** (mirrors `consensus.md`): for ANY of the three providers, if the call returns `result.isError` or an MCP/transport error, do not abort. Render that provider's section as: ``` - ** bottom line:** UNAVAILABLE (: ) + Worked in parallel: + - Codex (GPT) gpt-5.5 reasoning: n/a + - Gemini auto-gemini-3 reasoning: n/a + - Grok (xAI) grok-4.3 reasoning: n/a + - OpenRouter / deepseek-v4-pro deepseek/deepseek-v4-pro reasoning: n/a + - OpenRouter / kimi-k2-thinking moonshotai/kimi-k2-thinking reasoning: n/a ``` - and continue the comparison with the surviving providers. Common cases: Grok `missing-auth` (no `XAI_API_KEY`), `rate-limit`, `timeout`, Gemini `timeout`. Require **at least one** successful provider. If ALL THREE fail, skip the verdict comparison and emit exactly: + + If a field is missing from a result, print `unknown` for it (never invent a value). + +7. **Synthesize comparison** - read the `results` array (each result's `provider` + `text`) + and produce the structure below. A result with `isError: true` is NOT a command failure: + render that delegate as + `** bottom line:** UNAVAILABLE (: )` + and continue with the surviving delegates. Common cases: Grok `missing-auth` (no + `XAI_API_KEY`), `rate-limit`, `timeout`, Gemini `timeout`. Require **at least one** result + with `isError: false`. If EVERY result is an error, skip the verdict comparison and emit + exactly: ``` ## All providers unavailable - - GPT: : - - Gemini: : - - Grok: : + - : : + - ... (one line per result) No second opinion could be obtained. Re-run after resolving the above (often: missing key, rate-limit, or restart Claude Code). ``` - **Files:** when local files are referenced, keep the prompt text identical - across all three providers but deliver the file per provider: pass `files:[{path}]` - (or `{dir}` for whole directories) to **Grok**. Path/dir entries accept `mode: - "auto" | "inline" | "upload"` (default `"upload"`); use `mode: "auto"` for - source-code review so Grok reads text files line-by-line via `input_text` instead - of treating them as searchable `input_file` attachments. Resolution is against - `roots[]` (first-root-wins, supports cross-repo when multiple absolute dirs are - passed) or `cwd` when `roots` is omitted. Uploaded files are SHA-256 dedup-cached - locally so repeated `/ask-all` calls on the same files upload nothing on subsequent - runs (inline files always cost prompt tokens but are always fully read). For - **GPT** and **Gemini**, name the file path in the shared prompt so they read it - directly from `cwd` (optionally add its directory to the Gemini call's - `include-directories`). A Grok `file-read` / `file-too-large` / `missing-auth` - only degrades Grok's section (UNAVAILABLE) - the others still answer. Full - reference: `TECHNICAL.md` § "Grok files and cleanup". - - **Grok context parity (CRITICAL):** GPT (Codex) and Gemini (agy) both walk the - filesystem at `cwd` via `sandbox: "read-only"` - they can `ls`, glob, and read any - file in the repo. Grok CANNOT. Grok only sees what is in the `files` array of the - MCP call. For any open-ended, repo-wide question ("improve this repo", "audit this - code", "what are tradeoffs in our architecture"), Grok will answer from the textual - architecture description alone and lose every round against GPT/Gemini who cite - real lines. To level the field, ALWAYS attach an orientation bundle to Grok when - no specific files are referenced: - - 1. Pick 2-6 high-signal files: project `CLAUDE.md` / `AGENTS.md` / `README.md`, - top-level entrypoints (`main.tf`, `package.json`, `app.py`, `Cargo.toml`, - `pyproject.toml`, etc.), and any module the question is clearly about. For a - whole directory, prefer a `{ dir }` entry over enumerating files (defaults skip - `.git`, `node_modules`, etc; hard caps `maxFiles=50` / `maxBytes=128MB`). - 2. Pass them as `files: [{ path: "CLAUDE.md", mode: "auto" }, { path: "main.tf", mode: "auto" }, { dir: "src", include: ["**/*.ts"], mode: "auto" }, ...]` - with `cwd` = repo root. `mode: "auto"` is strongly recommended for source-code - review (inlines small text files for line-by-line reading). For cross-repo - questions, pass `roots: [repoA, repoB]` and either relative paths - (first-root-wins) or absolute paths (must resolve under one of the roots). - 3. Stay under 48 MB per file. `{ dir }` enforces its own `maxFiles` / `maxBytes` - caps - raise them on the entry if the default is too tight, or narrow `include`. - 4. State the attached set in the prompt so Grok knows what evidence it has - ("Attached: CLAUDE.md, main.tf, app/app.py - reason from these."). - 5. Fallback when `CLAUDE.md`/`AGENTS.md` is absent: substitute `README.md`, - then the top-level entrypoint inferred from project type (e.g. `package.json` - for Node, `pyproject.toml` for Python, `main.tf` for Terraform). - - **Verification:** before sending the prompt, sanity-check that the `files` array - passed to `mcp__grok__grok` is non-empty for any repo-wide question. If it is - empty, either build the bundle or document the skip in the synthesis. - - If you skip this for a whole-repo question, NOTE the asymmetry in the synthesis - ("Grok answered without repo files; discount its specificity"). - -7. **Synthesize comparison** - output structure: + Otherwise output: ``` ## Verdict comparison **GPT bottom line:** [1-2 sentences] **Gemini bottom line:** [1-2 sentences] **Grok bottom line:** [1-2 sentences] - **OpenRouter: bottom line:** [1-2 sentences] (one line per selected delegate) + **OpenRouter: bottom line:** [1-2 sentences] (one line per dispatched delegate) **Agreement:** [where they converge] **Disagreement:** [where they diverge - call out specifics] @@ -204,16 +133,14 @@ User question or topic: $ARGUMENTS ## Rules -- **Identical prompts** - all three providers receive byte-identical input. No "GPT said..." leakage into the Gemini or Grok prompt, etc. -- **Single-shot only** - never reuse `threadId` from prior calls. Each invocation creates three fresh threads. -- **Parallel, not sequential** - all three MCP tool calls in one message. Sequential dispatch wastes wall time. -- **Concurrent prep** - the full prep read set (expert Glob, `config.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, Grok env, `openrouter-list`) runs in ONE message (Step 0), not sequential turns; then dispatch all providers in ONE parallel message. The `invalidModels` `AskUserQuestion` is the one allowed serial gate. See `rules/delegator/orchestration.md` Step 5.5. -- **Advisory only** - `sandbox: "read-only"` for all three. Grok cannot list or glob the repo (it sees only files passed in `files`), and the xAI HTTP bridge cannot edit files at all, so `/ask-all` is never an implementation command. For Grok context parity on whole-repo questions, see the "Grok context parity (CRITICAL)" block in step 6. -- **Pin Gemini model** - always `model: "auto-gemini-3"`. Grok uses its bridge default (`GROK_DEFAULT_MODEL` or `grok-4.3`). +- **Identical prompt** - every delegate receives byte-identical input. The server forwards the same `prompt` and `developerInstructions` to all of them; no "GPT said..." leakage between delegates. +- **Single-shot only** - one `mcp__deliberation__ask-all` call per invocation. The server starts a fresh advisory thread per delegate; do not chain prior threads. +- **One call, server fans out** - a single tool call replaces N parallel provider calls. The server dispatches the delegates concurrently, so the host harness cannot stagger them. +- **Selection is server-side** - the server decides which built-ins are enabled and which OpenRouter aliases are eligible (applying `askAll`, expert eligibility, and the fanout cap). The command never reads `config.json`, never lists aliases, and never names an alias. This is the structural fix for the disabled-alias dispatch gap: a disabled or stale alias cannot be dispatched. +- **Advisory only** - all delegates run advisory; `/ask-all` is never an implementation command. - **Disagreement is signal** - when the models diverge, treat it as a flag to dig deeper, not a tie to break by majority. Often more than one is partly wrong. - **Never paste raw output** - always synthesize. - -- **Final judgment is the orchestrator's** - the three models advise in parallel. Claude compares them, applies its own judgment, and is accountable for the synthesized recommendation. Agreement among models is input, not an automatic verdict. +- **Final judgment is the orchestrator's** - the delegates advise in parallel. Claude compares them, applies its own judgment, and is accountable for the synthesized recommendation. Agreement among models is input, not an automatic verdict. diff --git a/commands/consensus.md b/commands/consensus.md index 585b675..24c9425 100644 --- a/commands/consensus.md +++ b/commands/consensus.md @@ -1,7 +1,7 @@ --- name: consensus description: Arbiter-mediated consensus - GPT + Gemini + Grok (plus any configured OpenRouter delegates) review while Claude commits a blind verdict, adjudicates, and synthesizes. Converges only with cross-model agreement. Max 5 rounds. -allowed-tools: mcp__codex__codex, mcp__gemini__gemini, mcp__grok__grok, mcp__openrouter__openrouter, mcp__openrouter__openrouter-list, Read, Bash +allowed-tools: mcp__deliberation__consensus, Read, Bash timeout: 900000 --- @@ -34,13 +34,13 @@ Plan, design, spec, or proposal to refine: $ARGUMENTS - Architecture / design tradeoffs → Architect - Security / threat modeling → Security Analyst - Code review of a concrete diff → Code Reviewer -2. Read expert prompt ONCE via this resolution sequence (fire this `Glob` in the SAME parallel message as the step 6 concurrent-prep reads - see step 6): +2. Read expert prompt ONCE via this resolution sequence: 1. Glob `~/.claude/plugins/cache/*/claude-delegator/*/prompts/[expert].md`. Pick the match with the highest semver version segment (the segment immediately after `claude-delegator/`, parsed as semver - not lexical string compare). 2. If no match, look up the inlined fallback under the heading `## Inlined fallback - [Expert]` in this command file (see end of this file). 3. If neither found, abort with: `Error: claude-delegator plugin cache missing for expert "[Expert]". Run /plugin install claude-delegator or /reload-plugins.` - Reuse the loaded contents across all rounds. -3. **Set cwd**: use `process.cwd()` as the MCP `cwd` for every call; agy print mode needs no folder-trust pre-check (Grok and Codex have no trusted-directory concept either). + Reuse the loaded contents across all rounds (pass it as `developerInstructions` to the consensus tool). +3. **Set cwd**: use `process.cwd()` as the MCP `cwd` for every call; the server resolves each provider's working directory from it. 4. Initialize state: - `plan` = original `$ARGUMENTS` - `round` = 0 @@ -53,29 +53,10 @@ Plan, design, spec, or proposal to refine: $ARGUMENTS /consensus: starting consensus loop (max 5 rounds, expert=[Expert]) ``` -6. **Concurrent prep + build the OpenRouter voting panel.** Run the prep ONCE in a single parallel message (concurrent prep, single dispatch): the expert-prompt `Glob` (step 2), `Read` `~/.claude/claude-delegator/config.json`, `mcp__openrouter__openrouter-list` with `mode:"consensus"` and `expert:"[chosen expert]"` (returns the server-selected voting panel as `selected`), and the round-1 status-block sources - `Read` `~/.codex/config.toml`, `Read` `~/.gemini/settings.json`, `Bash` `echo "$GROK_DEFAULT_MODEL" "$GROK_REASONING_EFFORT"` - all in ONE message, not sequential turns. Build the panel + round-1 status block from those cached results (the `invalidModels` `AskUserQuestion` below is the one allowed serial gate). **MANDATORY, every invocation:** re-run this prep (read `config.json` AND call `openrouter-list`) at the start of every `/consensus` run; NEVER reuse a cached config, panel, or `selected` set from an earlier run this session - `config.json` hot-reloads and a model's `consensus` flag may have changed. - - From the cached `~/.claude/claude-delegator/config.json` read, take `providers.*.enabled` (a built-in - with `enabled:false` is excluded from this run even if registered). - - From the cached `mcp__openrouter__openrouter-list` result: if unavailable / `error` set (a hard config - failure - bad JSON, schema, version, or maxFanout), there are no OpenRouter voices. - Otherwise the returned `delegates` are the valid models; `invalidModels` (if non-empty) - are entries the bridge skipped per-entry (each `{ index, alias, reason, suggestedAlias? }`). - - **If `invalidModels` is non-empty, do NOT silently drop them.** PRINT a short report - (one line per entry: `alias|index` + `reason` + `-> suggestedAlias` when present), then - ask with `AskUserQuestion` (first option is the pre-selected default): - 1. **Fix & proceed (Recommended)** - apply each `suggestedAlias` by `Edit`ing - `~/.claude/claude-delegator/config.json`; drop + note entries with no safe fix; - re-call `openrouter-list` (again with `mode:"consensus"` + `expert`) and use the - resulting `selected` panel. - 2. **Run valid only** - leave config untouched; use the returned `selected` as-is, note - the skipped `invalidModels` entries. - 3. **Skip all OpenRouter** - no OpenRouter voices this run. - - **The voting panel = the `selected` array returned by `openrouter-list`** (the bridge - already applied `consensus == true` + expert eligibility via its canonical routing; the - consensus panel is intentionally NOT bounded by `maxFanout`). Do NOT re-derive the panel - here. - - If the OpenRouter voting panel size is > 3, PRINT before the first dispatch: - `Warning: N OpenRouter voting models x up to 5 rounds x the inlined repo bundle = significant token cost AND a stricter convergence bar (every responding voice must APPROVE).` +6. **Load the expert prompt + status-block sources.** Run the expert-prompt `Glob` (step 2) and the round-1 status-block sources - `Read` `~/.codex/config.toml`, `Read` `~/.gemini/settings.json`, `Bash` `echo "$GROK_DEFAULT_MODEL" "$GROK_REASONING_EFFORT"` - to build the round-1 status block. Voting-panel selection (which built-ins are enabled, which OpenRouter aliases vote) is resolved server-side by `mcp__deliberation__consensus` on each round's call: the server applies `providers.*.enabled` and per-alias `consensus` eligibility from the live, hot-reloaded `config.json` itself. The command does not read `config.json` or list aliases, and never names an alias. + - The server returns the voting panel implicitly as the `opinions` array of each consensus call: one entry per dispatched voice, where `provider` is `codex`, `gemini`, `grok`, or `openrouter:`. The status block (step 4 of the round loop) is built from that array. + - The consensus panel is intentionally NOT bounded by a fanout cap. If the returned `opinions` array has > 3 voices, PRINT before continuing: + `Warning: N voting models x up to 5 rounds = significant token cost AND a stricter convergence bar (every responding voice must APPROVE).` ### Round loop (rounds 1..5) @@ -116,88 +97,33 @@ For each round R: ``` This is the orchestrator's PEER vote. Emitting it in an earlier message than the dispatch makes the pre-commitment visible in the transcript. Claude must NOT edit the blind verdict after seeing reviewers; it appears verbatim in the final report. (Claude's *adjudication* in step 7 is a separate, arbiter-role decision, recorded distinctly.) -4. **Parallel dispatch** - in the NEXT message, all three calls in ONE message with three tool blocks. Identical prompt body, identical expert prompt. On **round 1 only**, print the delegate status block first (see "Report as you go" for the format and sources) so the panel's exact models and reasoning efforts are visible before any dispatch; the panel is stable across rounds, so later rounds reuse the per-round status line only: +4. **Dispatch the round** - in the NEXT message, make ONE call to `mcp__deliberation__consensus` with the round-R prompt. The server selects the voting panel (enabled built-ins + eligible OpenRouter aliases) and fans out to all of them in parallel on fresh advisory threads. On **round 1 only**, print the delegate status block first (see "Report as you go" for the format), built from the returned `opinions` array, so the panel's models are visible; the panel is stable across rounds, so later rounds reuse the per-round status line only: ``` - mcp__codex__codex({ + mcp__deliberation__consensus({ prompt: "[identical 7-section prompt for round R]", - "developer-instructions": "[expert prompt]", - sandbox: "read-only", + expert: "[chosen expert]", cwd: "[cwd]" }) - - mcp__gemini__gemini({ - prompt: "[identical 7-section prompt for round R]", - "developer-instructions": "[expert prompt]", - sandbox: "read-only", - model: "auto-gemini-3", - cwd: "[cwd]" - }) - - mcp__grok__grok({ - prompt: "[identical 7-section prompt for round R]", - "developer-instructions": "[expert prompt]", - sandbox: "read-only", - cwd: "[repo root - same cwd as the other calls]", - roots: ["[absolute repo root]"], // optional; for cross-repo plans pass multiple - files: [{ path: "path/relative/to/root" }] // attach referenced files by default - }) - ``` - **Files:** if the plan under review references local files, pass them to - Grok via `files:[{path}]` (or `{dir}` for whole directories) each round. Path/dir - entries accept `mode: "auto" | "inline" | "upload"` (default `"upload"`); use - `mode: "auto"` so text files inline as `input_text` and Grok reads them - line-by-line instead of as searchable attachments — this is the difference - between a citing review and a hand-wavy one. Resolution is against `roots[]` - (first-root-wins) or `cwd` when `roots` is omitted; a path outside every root is - refused. For cross-repo plans (auditing two services together) pass `roots: - [repoA, repoB]`. Uploaded files are SHA-256 dedup-cached locally so the same - bundle on rounds 2-5 uploads nothing (inline files always cost prompt tokens - but are always fully read). GPT and Gemini read the named paths from their - `cwd`. Full reference: `TECHNICAL.md` § "Grok files and cleanup". - - **Grok context parity (CRITICAL):** GPT and Gemini walk the filesystem at `cwd` - under `sandbox: "read-only"`; Grok only sees files in the `files` array. For any - plan that asks reviewers to verify against the repo (cross-file invariants, - "audit this codebase", architectural claims), attach the same orientation bundle - to Grok EVERY round (same `files` payload so the comparison stays fair): - project `CLAUDE.md` / `AGENTS.md` if present, top-level entrypoints, and the - modules the plan touches - 2-6 files, under 48 MB total. Fallback when - `CLAUDE.md`/`AGENTS.md` is absent: substitute `README.md`, then the top-level - entrypoint inferred from project type. If you knowingly skip the bundle, - mark Grok's verdict as `ERRORED (context-starved)` so the convergence parser - (which keys on `ERRORED`, same format as `ERRORED (provider error: ...)` from - step 5) excludes it - never let an uninformed APPROVE drive convergence. - - **Verification (scoped to plans that touch the repo):** if the plan asks - reviewers to verify against the repo, before each round's parallel dispatch - sanity-check that the `files` array passed to `mcp__grok__grok` is non-empty. - The default is to keep the bundle stable round-over-round so reviewer - comparisons stay fair. However, if iterative plan refinement adds or - changes which modules the plan touches, update the bundle to match the - new touch-set (otherwise Grok is frozen while GPT/Gemini can freely read - the new files via `cwd` - reintroducing the exact asymmetry this section - exists to prevent). Record the bundle change in `history[R].dismissals` - with reason `"bundle updated: plan touch-set changed"` so the audit trail - shows why round-over-round comparison shifted. If `files` would still be - empty after refresh, mark Grok `ERRORED (context-starved)` for that round. - For purely conceptual consensus loops (no repo files relevant to the - plan), the verification check does NOT apply - run Grok with an empty - `files` array as a normal parallel reviewer. - - For EACH OpenRouter voting-panel delegate, add a parallel tool block: - ``` - mcp__openrouter__openrouter({ - prompt: "[identical 7-section prompt for round R]", - "developer-instructions": "[expert prompt]", - alias: "[delegate alias]", - sandbox: "read-only", - cwd: "[repo root]", - files: [ /* SAME orientation bundle passed to Grok each round */ ] - }) ``` - Each OpenRouter delegate is one independent external voice in the convergence count. An - errored delegate is marked `ERRORED` and excluded from the convergence bar (same as a - built-in), so a flaky model never blocks convergence. + + The tool returns `{ opinions: DelegationResult[], verdict: DelegationResult | null, error? }`. + - `opinions` is the cross-review panel: one `{ provider, model, text?, isError, errorKind?, ms }` per dispatched voice, where `provider` is `codex`, `gemini`, `grok`, or `openrouter:`. These are the independent external votes for this round. + - `verdict` is the server's own arbiter pass (the first provider in the panel synthesizes over the opinions). **Claude is the real arbiter in this command** - treat the server `verdict` as one ADDITIONAL voice to contrast, not as the final answer. Claude reads the `opinions` array, runs its own blind verdict and adjudication (steps 3 and 7 below), and authors the converged plan. Optionally surface the server `verdict` as a contrasting view in the round history. + - If `error` is `"all-providers-failed"`, every voice errored this round: no responding external, so the round CANNOT converge (see step 8). Handle it with the all-unavailable path described in the Stability rules. + + **Repo-wide context (file-blind voices):** the server fans out to advisory voices, + some of which (notably Grok and OpenRouter aliases) see ONLY what the `prompt` names - + they do not walk the filesystem. For any plan that asks reviewers to verify against the + repo (cross-file invariants, "audit this codebase", architectural claims), embed the + orientation context directly in the round-R `prompt` so the comparison stays fair: + name 2-6 high-signal files (project `CLAUDE.md` / `AGENTS.md` if present, top-level + entrypoints, the modules the plan touches) and paste or summarize their load-bearing + parts. Fallback when `CLAUDE.md`/`AGENTS.md` is absent: substitute `README.md`, then + the top-level entrypoint inferred from project type. Keep this context stable + round-over-round, but refresh it when iterative refinement changes which modules the + plan touches (record the change in `history[R].dismissals` with reason + `"context updated: plan touch-set changed"`). For purely conceptual consensus loops (no + repo files relevant to the plan), no orientation context is needed. 5. **Stream short status as each return arrives**. Do not wait until all are back to print anything. Mark a provider that returned an MCP error or `result.isError` as ERRORED. Examples: ``` @@ -371,22 +297,21 @@ If there were zero fallbacks across the whole loop, render `**Parse fallbacks**: ## Stability rules -- **Always dispatch in parallel** - all three MCP calls in the same message. Sequential triples wall time. -- **Concurrent prep** - run the Setup prep (expert Glob + `config.json` + `openrouter-list` + the round-1 status sources `~/.codex/config.toml` / `~/.gemini/settings.json` / Grok env) in ONE parallel message before the round loop, not sequential turns. The `invalidModels` `AskUserQuestion` is the one allowed serial gate. See `rules/delegator/orchestration.md` Step 5.5. -- **Single-shot per round** - fresh thread each call. Do NOT use `*-reply` with stored threadId. Cross-round state lives in the prompt body, not in provider memory. Avoids contamination if one provider went off track. -- **`cwd` for Gemini** - use `process.cwd()` (Setup step 3). agy print mode needs no folder-trust pre-check, so there is nothing to abort on. -- **Provider failure does not kill the loop** - if a provider errors (timeout, Grok `missing-auth`, transient API error), mark it ERRORED with a note `"provider error: "` and EXCLUDE it from the convergence bar for that round (it counts as neither APPROVE nor REQUEST CHANGES). The loop still converges when every responding external and Claude APPROVE and at least one external responded. If ALL externals errored in a round, there is no responding external, so that round cannot converge. -- **Pin Gemini model** - always `model: "auto-gemini-3"`. Grok uses its bridge default (`GROK_DEFAULT_MODEL` or `grok-4.3`); no in-command pin. -- **Claude cannot self-approve into consensus** - convergence requires every responding external to APPROVE and at least one external to respond; Claude's APPROVE alone never converges. Claude's blind verdict is a peer vote; its adjudication is a separate, accountable role. +- **One call per round, server fans out** - a single `mcp__deliberation__consensus` call per round replaces N parallel provider calls. The server dispatches the panel concurrently, so the host harness cannot stagger them. +- **Selection is server-side** - the server picks the voting panel (enabled built-ins + eligible OpenRouter aliases) from the live `config.json` on each call. The command never reads `config.json`, never lists aliases, and never names an alias. A model's `consensus` flag hot-reloads, so each round reflects the current config. +- **Single-shot per round** - one tool call per round; the server starts a fresh advisory thread per voice. Cross-round state lives in the prompt body, not in provider memory. Avoids contamination if one voice went off track. +- **`cwd`** - use `process.cwd()` (Setup step 3); the server resolves each provider's working directory from it. +- **Provider failure does not kill the loop** - the server isolates failures: a voice that errors comes back in `opinions` with `isError: true` (mark it ERRORED), and is EXCLUDED from the convergence bar for that round (neither APPROVE nor REQUEST CHANGES). The loop still converges when every responding external and Claude APPROVE and at least one external responded. If `error: "all-providers-failed"`, no external responded, so that round cannot converge. +- **Claude cannot self-approve into consensus** - convergence requires every responding external to APPROVE and at least one external to respond; Claude's APPROVE alone never converges. Claude's blind verdict is a peer vote; its adjudication is a separate, accountable role. The server `verdict` is one more voice to contrast, never the binding result. - **No silent dismissal** - every `dismiss`/`defer` of a critical issue (from a reviewer OR from Claude's own blind verdict) carries a one-line reason that appears in the final report. Repeated cross-source issues are accepted by default. - **Hard cap at 5 rounds** - even if one reviewer is being stubborn, terminate. Diverging too many rounds usually means the plan has an unresolved ambiguity, not that the reviewer is wrong. -- **Report as you go** - before the round-1 dispatch, print a per-delegate status block (one line per voting member: provider, exact model, reasoning effort), then print a status line after each round dispatch and after each return. Long silences look like a hang. Resolve each member's model + effort from its real source, never invent: Codex from `~/.codex/config.toml` (`model` / `model_reasoning_effort`, missing = `default`); Gemini model from `~/.gemini/settings.json` (`model.name`, default `auto-gemini-3`) with effort `n/a` (agy has no knob); Grok = `$GROK_DEFAULT_MODEL` else `grok-4.3` / `$GROK_REASONING_EFFORT` else `high`; OpenRouter voting delegates straight from `openrouter-list` (`model` + resolved `reasoning_effort`, `null` prints as `default`). Print `unknown` for any field whose source can't be read. Example: +- **Report as you go** - after the round-1 `mcp__deliberation__consensus` call returns, print a per-delegate status block (one line per voting member: provider, model, reasoning effort), then print a status line for each round. Long silences look like a hang. Source every line from the returned `opinions` array: the `provider` label and its `model`. Reasoning effort is not carried in the result, so print `n/a` in that column. Print `unknown` for any field missing from a result; never invent a value. The panel is stable across rounds, so print the full block once (round 1) and a short per-round status line thereafter. Example: ``` Consensus panel (round 1, typical 30-60s/round): - - Codex (GPT) gpt-5.5 reasoning: high + - Codex (GPT) gpt-5.5 reasoning: n/a - Gemini auto-gemini-3 reasoning: n/a - - Grok (xAI) grok-4.3 reasoning: high - - OpenRouter / kimi-k2-thinking moonshotai/kimi-k2-thinking reasoning: high + - Grok (xAI) grok-4.3 reasoning: n/a + - OpenRouter / kimi-k2-thinking moonshotai/kimi-k2-thinking reasoning: n/a ``` - **Synthesize, never paste raw** - reviewers' raw output never appears verbatim in the final report. - **Stage 2 is decision input only** - Stage 2 fires conditionally (step 6); its candidate issues feed step 7's existing adjudication. Stage 2 status (`fired`/`skipped`/`errored`) does NOT participate in the convergence rule. The convergence rule in step 8 reads only Stage 1 verdicts + Claude's adjudication + accepted critical issues (from any source, Stage 1 or Stage 2 alike). From 826be401117225a9ed8d4c7ec0583b5b9bd6ca62 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 17:41:16 +0200 Subject: [PATCH 16/19] ci(validate): npm ci before typecheck so tsc devDep is installed --- .github/workflows/validate.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f80f950..da77bd9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,6 +26,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci - name: Typecheck run: npm run typecheck From 54fe082a0fde5c952299a839996b1beca28e1a54 Mon Sep 17 00:00:00 2001 From: Anton Babenko Date: Sat, 30 May 2026 18:01:46 +0200 Subject: [PATCH 17/19] fix(setup): register deliberation MCP server so /ask-all and /consensus work --- commands/setup.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/commands/setup.md b/commands/setup.md index 729c5a8..1ef6f41 100644 --- a/commands/setup.md +++ b/commands/setup.md @@ -127,6 +127,19 @@ if [ "$OR_ON" = "1" ]; then fi ``` +### Deliberation (unified fan-out server) + +The deliberation server powers `/ask-all` and `/consensus`. It selects and +dispatches the active delegate set (enabled built-ins plus eligible OpenRouter +aliases) in one server call, so a disabled alias can never be dispatched from a +stale client-side selection. Register it unconditionally - it fans out to +whatever providers are enabled. + +```bash +claude mcp remove deliberation >/dev/null 2>&1 || true +claude mcp add --transport stdio --scope user deliberation -- node '${CLAUDE_PLUGIN_ROOT}/server/mcp/index.js' +``` + **Codex model (optional):** by default Codex inherits its model, sandbox, and approval policy from `~/.codex/config.toml` (the `model` key) - change it there and every provider that reads that file follows. To pin a model on the MCP server From a24bbf771b5619bc3f8f71361b6b3bd1b649bb04 Mon Sep 17 00:00:00 2001 From: Anton Babenko <393243+antonbabenko@users.noreply.github.com> Date: Sat, 30 May 2026 22:16:02 +0200 Subject: [PATCH 18/19] feat(plugin)!: rebrand claude-delegator -> deliberation + namespace bridge MCPs (#54) * feat(core): add deliberation config/cache path resolver with legacy fallback * refactor(server): rename claude-delegator->deliberation; wire path resolver; namespace serverInfo * docs(core): update config path reference in registry * refactor(commands): namespace bridge tool ids + rename to deliberation; dual-path expert-prompt glob; idempotent MCP migration * refactor(rules): namespace bridge tool ids + rename to deliberation * feat(plugin)!: rename plugin claude-delegator->deliberation (manifests, docs, assets) * test: update grok/openrouter mocks for deliberation rename * refactor: namespace remaining bare tool ids + deliberation config-path detection in docs/commands * docs(plugin): host-neutral description, synced with marketplace PR #38 * docs(plugin): align description verbatim with marketplace PR #38 * refactor(setup): collapse to one main Bash call, drop destructive rm, no health handshakes * refactor(uninstall): one isolated removal block, cover new+legacy cache/rules paths * fix(setup): default alias-overwrite to Yes (recommended) * refactor!: drop claude-delegator backward-compat (deliberation-only paths, globs, env) * docs: deliberation-only paths + Upgrade from 1.x section * docs: namespace remaining stale bare tool-ids (deliberation-*) * docs: namespace remaining bare tool-ids in CLAUDE.md + CONTRIBUTING.md * docs: namespace last bare tool-id in CONTRIBUTING.md --- .claude-plugin/marketplace.json | 18 +- .claude-plugin/plugin.json | 8 +- CLAUDE.md | 20 +- CONTRIBUTING.md | 20 +- README.md | 56 ++-- TECHNICAL.md | 42 +-- assets/consensus-flow-simple.html | 2 +- assets/consensus-flow.html | 2 +- assets/social-preview.html | 4 +- commands/ask-all.md | 12 +- commands/ask-gemini.md | 14 +- commands/ask-gpt.md | 14 +- commands/ask-grok.md | 16 +- commands/ask-openrouter.md | 24 +- commands/consensus.md | 6 +- commands/grok-files.md | 12 +- commands/setup.md | 485 +++++++++--------------------- commands/uninstall.md | 118 ++++---- core/paths.js | 68 +++++ core/registry.js | 2 +- package-lock.json | 8 +- package.json | 2 +- rules/model-selection.md | 24 +- rules/orchestration.md | 48 +-- rules/triggers.md | 8 +- server/gemini/index.js | 6 +- server/grok/cache.js | 4 +- server/grok/files-admin.js | 6 +- server/grok/index.js | 10 +- server/mcp/index.js | 4 +- server/openrouter/index.js | 11 +- test/core-paths.test.js | 76 +++++ test/grok-gc.test.js | 4 +- test/grok.test.js | 8 +- test/openrouter.test.js | 36 ++- 35 files changed, 581 insertions(+), 617 deletions(-) create mode 100644 core/paths.js create mode 100644 test/core-paths.test.js diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 562c4a5..e25d96f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,21 +1,21 @@ { - "name": "antonbabenko-claude-delegator", + "name": "antonbabenko-deliberation", "owner": { "name": "Anton Babenko", "url": "https://github.com/antonbabenko" }, "plugins": [ { - "name": "claude-delegator", + "name": "deliberation", "source": "./", - "description": "GPT (Codex CLI), Gemini (bundled MCP bridge), and Grok (xAI HTTP bridge) expert subagents for Claude Code. Seven specialized experts: Architect, Plan Reviewer, Scope Analyst, Code Reviewer, Security Analyst, Researcher, Debugger.", + "description": "Use when you want a delegated second opinion or implementation from GPT (Codex), Gemini, Grok (xAI), or OpenRouter (config-driven, 400+ models) - seven expert subagents (Architect, Plan Reviewer, Scope Analyst, Code Reviewer, Security Analyst, Researcher, Debugger) and bundled ask-gpt/ask-gemini/ask-grok/ask-openrouter/ask-all/consensus commands, advisory (read-only) or implementation (write; Grok and OpenRouter are advisory-only).", "version": "1.18.0", "author": { "name": "Anton Babenko", "url": "https://github.com/antonbabenko" }, - "homepage": "https://github.com/antonbabenko/claude-delegator", - "repository": "https://github.com/antonbabenko/claude-delegator", + "homepage": "https://github.com/antonbabenko/deliberation", + "repository": "https://github.com/antonbabenko/deliberation", "license": "MIT", "keywords": [ "delegation", @@ -27,11 +27,13 @@ "google", "grok", "xai", - "files", + "openrouter", "experts", "subagents", - "orchestration" + "orchestration", + "code-review", + "architecture" ] } ] -} +} \ No newline at end of file diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8f80b31..67806cf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,13 +1,13 @@ { - "name": "claude-delegator", - "description": "GPT (Codex CLI), Gemini (bundled MCP bridge), and Grok (xAI HTTP bridge) expert subagents for Claude Code. Seven specialized experts: Architect, Plan Reviewer, Scope Analyst, Code Reviewer, Security Analyst, Researcher, Debugger.", + "name": "deliberation", + "description": "Use when you want a delegated second opinion or implementation from GPT (Codex), Gemini, Grok (xAI), or OpenRouter (config-driven, 400+ models) - seven expert subagents (Architect, Plan Reviewer, Scope Analyst, Code Reviewer, Security Analyst, Researcher, Debugger) and bundled ask-gpt/ask-gemini/ask-grok/ask-openrouter/ask-all/consensus commands, advisory (read-only) or implementation (write; Grok and OpenRouter are advisory-only).", "version": "1.18.0", "author": { "name": "Anton Babenko", "url": "https://github.com/antonbabenko" }, - "homepage": "https://github.com/antonbabenko/claude-delegator", - "repository": "https://github.com/antonbabenko/claude-delegator", + "homepage": "https://github.com/antonbabenko/deliberation", + "repository": "https://github.com/antonbabenko/deliberation", "license": "MIT", "keywords": ["delegation", "mcp", "codex", "gpt", "openai", "gemini", "antigravity", "agy", "google", "grok", "xai", "files", "experts", "subagents", "orchestration"] } diff --git a/CLAUDE.md b/CLAUDE.md index c780e8b..bb2961d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,13 @@ A Claude Code plugin that provides GPT (via Codex CLI), Gemini 3 (via the Antigr ```bash # Test plugin locally (loads from working directory) -claude --plugin-dir /path/to/claude-delegator +claude --plugin-dir /path/to/deliberation # Run setup to test installation flow -/claude-delegator:setup +/deliberation:setup # Run uninstall to test removal flow -/claude-delegator:uninstall +/deliberation:uninstall ``` No build step, no dependencies. Codex exposes a native MCP server; Gemini, Grok, and OpenRouter use bundled zero-dependency Node bridges (`server/gemini/index.js`, `server/grok/index.js`, `server/openrouter/index.js`). The Gemini bridge wraps the Antigravity CLI (`agy`) in print mode. The OpenRouter bridge calls any OpenAI-compatible `/chat/completions` endpoint. @@ -25,7 +25,7 @@ No build step, no dependencies. Codex exposes a native MCP server; Gemini, Grok, ### Orchestration Flow -Claude acts as orchestrator—delegates to specialized experts based on task type. Supports both **single-shot** (independent calls) and **multi-turn** (context preserved via `threadId`). +Claude acts as orchestrator - delegates to specialized experts based on task type. Supports both **single-shot** (independent calls) and **multi-turn** (context preserved via `threadId`). ``` User Request → Claude Code → [Match trigger → Select expert & provider] @@ -44,7 +44,7 @@ User Request → Claude Code → [Match trigger → Select expert & provider] 1. **Match trigger** - Check `rules/triggers.md` for semantic patterns 2. **Read expert prompt** - Load from `prompts/[expert].md` 3. **Build 7-section prompt** - Use format from `rules/delegation-format.md` -4. **Call provider tool** - `mcp__codex__codex`, `mcp__gemini__gemini`, `mcp__grok__grok`, or `mcp__openrouter__openrouter` +4. **Call provider tool** - `mcp__deliberation-codex__codex`, `mcp__deliberation-gemini__gemini`, `mcp__deliberation-grok__grok`, or `mcp__deliberation-openrouter__openrouter` 5. **Synthesize response** - Never show raw output; interpret and verify ### The 7-Section Delegation Format @@ -62,11 +62,11 @@ Retries use multi-turn (`*-reply` with `threadId`) so the expert remembers previ | Component | Purpose | Notes | |-----------|---------|-------| -| `rules/*.md` | When/how to delegate | Installed to `~/.claude/rules/delegator/` | +| `rules/*.md` | When/how to delegate | Installed to `~/.claude/rules/deliberation/` | | `prompts/*.md` | Expert personalities | Injected via `developer-instructions` | | `commands/*.md` | Slash commands | `/setup`, `/uninstall` | | `config/providers.json` | Provider metadata | Not used at runtime | -| `~/.claude/claude-delegator/config.json` | OpenRouter model config | Live SSOT; stat-gated hot-reload | +| `~/.claude/deliberation/config.json` | OpenRouter model config | Live SSOT; stat-gated hot-reload | > Expert prompts adapted from [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) @@ -82,15 +82,15 @@ Retries use multi-turn (`*-reply` with `threadId`) so the expert remembers previ | **Researcher** | `prompts/researcher.md` | External libraries, docs, best practices | "how do I use X", "find examples of Y" | | **Debugger** | `prompts/debugger.md` | Root-cause analysis, minimal fixes | "why does this crash", "debug this failing test" | -Every expert can operate in **advisory** (`sandbox: read-only`) or **implementation** (`sandbox: workspace-write`) mode based on the task. OpenRouter models are always advisory - per-model expert eligibility is controlled by the `experts` field in `~/.claude/claude-delegator/config.json`. +Every expert can operate in **advisory** (`sandbox: read-only`) or **implementation** (`sandbox: workspace-write`) mode based on the task. OpenRouter models are always advisory - per-model expert eligibility is controlled by the `experts` field in `~/.claude/deliberation/config.json`. ## Grok file access -Grok reads attached files via `files[]` and resolves them under `roots[]` (top-level array of absolute directories) or `cwd`. `path` and `dir` entries take an optional `mode: "auto" | "inline" | "upload"` — inline embeds the file as `input_text` so Grok reads it line-by-line (best for source code); upload routes through the xAI Files API and is SHA-256 dedup-cached locally. `file_id` / `file_url` entries pass through unchanged and do not accept `mode`. Directory expansion via `{dir}` entries. See **[TECHNICAL.md: Grok files and cleanup](TECHNICAL.md#grok-files-and-cleanup)** for parameters, the inline-vs-upload tradeoff, cross-repo usage, cache layout, and the `gc` cleanup subcommand. +Grok reads attached files via `files[]` and resolves them under `roots[]` (top-level array of absolute directories) or `cwd`. `path` and `dir` entries take an optional `mode: "auto" | "inline" | "upload"` - inline embeds the file as `input_text` so Grok reads it line-by-line (best for source code); upload routes through the xAI Files API and is SHA-256 dedup-cached locally. `file_id` / `file_url` entries pass through unchanged and do not accept `mode`. Directory expansion via `{dir}` entries. See **[TECHNICAL.md: Grok files and cleanup](TECHNICAL.md#grok-files-and-cleanup)** for parameters, the inline-vs-upload tradeoff, cross-repo usage, cache layout, and the `gc` cleanup subcommand. ## Key Design Decisions -1. **Native & Bridge MCP** - Codex has a native `mcp-server` command. Gemini requires a bundled bridge (`server/gemini/index.js`) that wraps the Antigravity CLI (`agy`) in print mode. Grok has no MCP or CLI server mode, so a bundled bridge (`server/grok/index.js`) wraps the xAI **Responses API** (`/v1/responses`) directly - advisory-only (no file editing), but it can READ attached files (`files:[{path|file_id|file_url|dir}]`, optional `roots[]`, per-entry `mode` for upload-vs-inline delivery); uploaded files are SHA-256 dedup-cached locally, auto-expire (7-day default, `GROK_FILE_TTL_SECONDS`), and are managed with `/grok-files` (`server/grok/files-admin.js`: `list`/`prune`/`gc`). Details in [TECHNICAL.md § Grok files and cleanup](TECHNICAL.md#grok-files-and-cleanup). OpenRouter uses a bundled bridge (`server/openrouter/index.js`) that calls any OpenAI-compatible `POST {apiBase}/chat/completions` endpoint - advisory-only, text-inline file attachment only (`{path}`/`{dir}`; no upload path), config-driven via `~/.claude/claude-delegator/config.json`. Details in [TECHNICAL.md § OpenRouter bridge](TECHNICAL.md#openrouter-bridge). +1. **Native & Bridge MCP** - Codex has a native `mcp-server` command. Gemini requires a bundled bridge (`server/gemini/index.js`) that wraps the Antigravity CLI (`agy`) in print mode. Grok has no MCP or CLI server mode, so a bundled bridge (`server/grok/index.js`) wraps the xAI **Responses API** (`/v1/responses`) directly - advisory-only (no file editing), but it can READ attached files (`files:[{path|file_id|file_url|dir}]`, optional `roots[]`, per-entry `mode` for upload-vs-inline delivery); uploaded files are SHA-256 dedup-cached locally, auto-expire (7-day default, `GROK_FILE_TTL_SECONDS`), and are managed with `/grok-files` (`server/grok/files-admin.js`: `list`/`prune`/`gc`). Details in [TECHNICAL.md § Grok files and cleanup](TECHNICAL.md#grok-files-and-cleanup). OpenRouter uses a bundled bridge (`server/openrouter/index.js`) that calls any OpenAI-compatible `POST {apiBase}/chat/completions` endpoint - advisory-only, text-inline file attachment only (`{path}`/`{dir}`; no upload path), config-driven via `~/.claude/deliberation/config.json`. Details in [TECHNICAL.md § OpenRouter bridge](TECHNICAL.md#openrouter-bridge). 2. **Single-shot + multi-turn** - Single-shot for advisory (full context per call), multi-turn via `threadId` for chained implementation and retries 3. **Dual mode** - Any expert can advise or implement based on task 4. **Synthesize, don't passthrough** - Claude interprets expert output, applies judgment diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1494652..b915875 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to claude-delegator +# Contributing to deliberation Contributions welcome. This document covers how to contribute effectively. @@ -8,15 +8,15 @@ Contributions welcome. This document covers how to contribute effectively. ```bash # Clone the repo -git clone https://github.com/antonbabenko/claude-delegator -cd claude-delegator +git clone https://github.com/antonbabenko/deliberation +cd deliberation # Run `claude` and install plugin in Claude Code claude -/claude-delegator:setup +/deliberation:setup # Or test your changes locally without reinstalling -claude --plugin-dir /path/to/claude-delegator +claude --plugin-dir /path/to/deliberation ``` --- @@ -36,7 +36,7 @@ claude --plugin-dir /path/to/claude-delegator ## Project Structure ``` -claude-delegator/ +deliberation/ ├── .claude-plugin/ # Plugin manifest │ └── plugin.json ├── commands/ # Slash commands (/setup, /uninstall) @@ -53,7 +53,7 @@ claude-delegator/ ### Before Submitting -1. **Test your changes** - Run `/claude-delegator:setup` and verify +1. **Test your changes** - Run `/deliberation:setup` and verify 2. **Update docs** - If you change behavior, update relevant docs 3. **Keep commits atomic** - One logical change per commit @@ -94,7 +94,7 @@ You never bump versions by hand. 3. It opens a `chore(release): vX.Y.Z` PR that updates `version.json`, `CHANGELOG.md`, and the synced manifests, then auto-merges it once the `validate` check passes. 4. On merge, a second workflow tags `vX.Y.Z` and publishes the GitHub Release. -5. The `antonbabenko/agent-plugins` marketplace then re-pins `claude-delegator` to the new +5. The `antonbabenko/agent-plugins` marketplace then re-pins `deliberation` to the new release (immediately if its dispatch token is set, otherwise within a day via cron). `version.json` is the single source of truth. `.claude-plugin/plugin.json`, @@ -163,8 +163,8 @@ check fails if they drift. After changes, verify with actual MCP calls: 1. Install the plugin in Claude Code -2. Run `/claude-delegator:setup` -3. Verify MCP tools are available (`mcp__codex__codex`) +2. Run `/deliberation:setup` +3. Verify MCP tools are available (`mcp__deliberation-codex__codex`) 4. Test MCP tool calls via oracle delegation 5. Verify responses are properly synthesized 6. Test error cases (timeout, missing CLI) diff --git a/README.md b/README.md index bba3b05..d924f15 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Claude Delegator +# Deliberation Get a second opinion in Claude Code from GPT, Gemini, and Grok - plus 300+ more models through OpenRouter, including Qwen, Kimi, and DeepSeek. Seven domain experts (Architect, Code Reviewer, Security Analyst, and four more) review your plans, find bugs, and debate edge cases until they agree. @@ -29,7 +29,7 @@ When three models argue, the real bug reveals itself. Round 1 = independent top -## What is Claude Delegator? +## What is Deliberation? Claude can ask GPT, Gemini, Grok, or any OpenAI-compatible model (via OpenRouter) for help through MCP. The plugin handles the wiring for each provider so you just write the prompt. @@ -37,7 +37,7 @@ Each expert has a distinct specialty and can advise or implement. You can use any subset of the providers. The plugin detects which are configured and routes accordingly. OpenRouter is advisory-only and config-driven: models are declared in -`~/.claude/claude-delegator/config.json` and hot-reload without restarting Claude Code. +`~/.claude/deliberation/config.json` and hot-reload without restarting Claude Code. | What you get | Why it matters | |--------------|----------------| @@ -58,12 +58,12 @@ Inside a Claude Code instance, run: **2. Install the plugin** ``` -/plugin install claude-delegator@antonbabenko +/plugin install deliberation@antonbabenko ``` **3. Run setup** ``` -/claude-delegator:setup +/deliberation:setup ``` Claude now routes complex tasks to your GPT, Gemini, Grok, and OpenRouter experts (Grok and OpenRouter advise; GPT and Gemini can also implement). @@ -92,7 +92,7 @@ You need at least one provider: - **Codex CLI** (GPT): `npm install -g @openai/codex`, then `codex login`. - **Antigravity CLI**: [Getting Started with Antigravity CLI](https://antigravity.google/docs/cli-getting-started) and [Migrating from Gemini CLI](https://antigravity.google/docs/gcli-migration), then run `agy` and login. - **Grok (xAI)**: no CLI to install; the bridge ships with the plugin (needs Node 18+). Set `XAI_API_KEY` (get a key at https://console.x.ai). -- **OpenRouter**: no CLI; the bridge ships with the plugin (needs Node 18+). Set `OPENROUTER_API_KEY` (get a key at https://openrouter.ai/keys), then declare models in `~/.claude/claude-delegator/config.json`. Works with any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, HuggingFace Inference) - auth is skipped automatically when the key env var is empty. +- **OpenRouter**: no CLI; the bridge ships with the plugin (needs Node 18+). Set `OPENROUTER_API_KEY` (get a key at https://openrouter.ai/keys), then declare models in `~/.claude/deliberation/config.json`. Works with any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, HuggingFace Inference) - auth is skipped automatically when the key env var is empty. ## Commands @@ -100,15 +100,15 @@ Bundled with the plugin (available once installed): | Command | Purpose | |---------|---------| -| `/claude-delegator:setup` | Configure Codex/Gemini/Grok/OpenRouter MCP servers + orchestration rules | -| `/claude-delegator:consensus` | 🔥🔥🔥 Arbiter-mediated GPT + Gemini + Grok + Claude convergence loop | -| `/claude-delegator:ask-all` | 🔥 GPT + Gemini + Grok (+ configured OpenRouter models) in parallel, synthesized | -| `/claude-delegator:ask-gpt` | One-shot GPT (Codex) second opinion | -| `/claude-delegator:ask-gemini` | One-shot Gemini second opinion | -| `/claude-delegator:ask-grok` | One-shot Grok (xAI) second opinion (advisory-only) | -| `/claude-delegator:ask-openrouter` | One-shot OpenRouter model second opinion (advisory-only) | -| `/claude-delegator:uninstall` | Remove MCP config, rules, and aliases | -| `/claude-delegator:grok-files` | List, prune, or gc Grok-uploaded files (storage + local cache cleanup) | +| `/deliberation:setup` | Configure Codex/Gemini/Grok/OpenRouter MCP servers + orchestration rules | +| `/deliberation:consensus` | 🔥🔥🔥 Arbiter-mediated GPT + Gemini + Grok + Claude convergence loop | +| `/deliberation:ask-all` | 🔥 GPT + Gemini + Grok (+ configured OpenRouter models) in parallel, synthesized | +| `/deliberation:ask-gpt` | One-shot GPT (Codex) second opinion | +| `/deliberation:ask-gemini` | One-shot Gemini second opinion | +| `/deliberation:ask-grok` | One-shot Grok (xAI) second opinion (advisory-only) | +| `/deliberation:ask-openrouter` | One-shot OpenRouter model second opinion (advisory-only) | +| `/deliberation:uninstall` | Remove MCP config, rules, and aliases | +| `/deliberation:grok-files` | List, prune, or gc Grok-uploaded files (storage + local cache cleanup) | `/setup` can also install short aliases (`/ask-gpt`, `/ask-gemini`, `/ask-grok`, `/ask-all`, `/consensus`, `/grok-files`) into `~/.claude/commands/`. This is opt-in. Existing same-named commands are kept by default; setup asks before overwriting any of them. `/uninstall` removes an alias only if it is byte-identical to the bundled copy. @@ -202,8 +202,8 @@ Every expert supports two modes, chosen automatically from your request: ### OpenRouter config -OpenRouter models are declared in `~/.claude/claude-delegator/config.json` -(override path with `CLAUDE_DELEGATOR_CONFIG`). The file is the live single source of +OpenRouter models are declared in `~/.claude/deliberation/config.json` +(override the path with `DELIBERATION_CONFIG`). The file is the live single source of truth: changes to the `openrouter` block hot-reload without restarting Claude Code. Toggling a built-in provider (codex / gemini / grok) still requires `/setup`. @@ -262,6 +262,26 @@ see [TECHNICAL.md - OpenRouter bridge](TECHNICAL.md#openrouter-bridge). For provider defaults, environment variables, and manual MCP setup, see [TECHNICAL.md](TECHNICAL.md#environment-variables). +## Upgrade from 1.x (claude-delegator -> deliberation) + +2.0 renames the plugin and drops 1.x compatibility. One-time steps: + +1. Move your config: `mv ~/.claude/claude-delegator ~/.claude/deliberation` (or set + `DELIBERATION_CONFIG` to a custom path; `CLAUDE_DELEGATOR_CONFIG` is no longer read). +2. Re-run `/deliberation:setup` to register the renamed MCP servers. The old bare + `codex`/`gemini`/`grok`/`openrouter` registrations are not auto-removed. If you had them, + remove each one (the CLI takes one name per call): + ``` + claude mcp remove --scope user codex + claude mcp remove --scope user gemini + claude mcp remove --scope user grok + claude mcp remove --scope user openrouter + ``` +3. Slash commands moved from `/claude-delegator:*` to `/deliberation:*` (the short aliases + `/ask-gpt` etc. are unchanged). +4. Optional cleanup: `rm -rf ~/.claude/rules/delegator ~/.claude/cache/claude-delegator`. +5. Restart Claude Code. + ## Author Maintained by Anton Babenko - [LinkedIn](https://linkedin.com/in/antonbabenko), [X/Twitter](https://x.com/antonbabenko). @@ -272,7 +292,7 @@ Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow, ## Credits -Claude Delegator started as a fork of [jarrodwatts/claude-delegator](https://github.com/jarrodwatts/claude-delegator) - credit to Jarrod Watts for the original solution and inspiration. Original work and MIT copyright are retained. This fork adds Grok support, Gemini bridge reliability (timeout and trust recovery), provider configuration overrides, and the bundled delegation commands. It is not an official continuation of the upstream project. +Deliberation started as a fork of [jarrodwatts/claude-delegator](https://github.com/jarrodwatts/claude-delegator) - credit to Jarrod Watts for the original solution and inspiration. Original work and MIT copyright are retained. This fork adds Grok support, Gemini bridge reliability (timeout and trust recovery), provider configuration overrides, and the bundled delegation commands. It is not an official continuation of the upstream project. Expert prompts are adapted from [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) (snapshot `03eb9fff`, 2026-05-25) and [PAL MCP server](https://github.com/BeehiveInnovations/pal-mcp-server) (Apache-2.0, snapshot `7afc7c1`, 2026-05-25). diff --git a/TECHNICAL.md b/TECHNICAL.md index 3e1f8f4..ef341b8 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -1,6 +1,6 @@ # Technical Reference -Advanced and internal details for Claude Delegator. For install and everyday use, +Advanced and internal details for Deliberation. For install and everyday use, see the [README](README.md). This document covers the provider bridges, the full environment-variable reference, manual MCP setup, multi-turn and retry behavior, and the Gemini recovery paths. @@ -44,9 +44,9 @@ Claude: detects a security question, selects the Security Analyst | v +-------------------------------------+ - | mcp__codex__codex / | - | mcp__gemini__gemini / | - | mcp__grok__grok | + | mcp__deliberation-codex__codex / | + | mcp__deliberation-gemini__gemini / | + | mcp__deliberation-grok__grok | | -> Security Analyst prompt | | -> expert analyzes your code | +-------------------------------------+ @@ -116,7 +116,7 @@ it can read context to advise but cannot mutate the real workspace, even under A bundled zero-dependency Node bridge over the xAI Responses API (`/v1/responses`). It is advisory-only (it cannot edit files) but it can read attached files: pass `files: [{ path | file_id | file_url | dir }]` and the -bridge delivers them per the `mode` setting — uploaded to the xAI Files API +bridge delivers them per the `mode` setting - uploaded to the xAI Files API (default), inlined as `input_text` (for line-by-line reading of source files), or expanded via the bundled glob walker for directories. Resolution is against the top-level `roots: string[]` (first-root-wins) or `cwd` when `roots` is @@ -147,8 +147,8 @@ Codex has no bridge environment variables: it ships its own native MCP server an reads `~/.codex/config.toml` directly. The **model** comes from the `model` key in that file by default (the Codex analog of `GEMINI_DEFAULT_MODEL` / `GROK_DEFAULT_MODEL`). Override it on the server with `-c model=` on the -`claude mcp add ... codex` registration, or per call with the `model` parameter of -`mcp__codex__codex(...)`. See [Configuration in the README](README.md#configuration). +`claude mcp add ... deliberation-codex` registration, or per call with the `model` parameter of +`mcp__deliberation-codex__codex(...)`. See [Configuration in the README](README.md#configuration). ## Manual MCP setup @@ -224,7 +224,7 @@ budget is exhausted, the call fails with `errorKind: "timeout"` (still Grok reads attached files via the `files[]` parameter. Each entry has EXACTLY ONE of: -- `path` - a local file. Delivery is controlled by `mode` (default `"upload"` — bridge uploads to the xAI Files API; `"inline"` embeds as `input_text`; `"auto"` picks per heuristic — see "Inline vs upload delivery" below). +- `path` - a local file. Delivery is controlled by `mode` (default `"upload"` - bridge uploads to the xAI Files API; `"inline"` embeds as `input_text`; `"auto"` picks per heuristic - see "Inline vs upload delivery" below). - `file_id` - an already-uploaded xAI file id (passed through, no upload). - `file_url` - a public URL (passed through). - `dir` - a local directory expanded recursively. Same `mode` rules; the walker @@ -239,7 +239,7 @@ that escape via `realpath` are also refused. An oversize file (>48 MB) returns ### Cross-repo example ```js -mcp__grok__grok({ +mcp__deliberation-grok__grok({ prompt: "Compare the auth strategy in these two services.", cwd: "/Users/me/work/service-a", roots: ["/Users/me/work/service-a", "/Users/me/work/service-b"], @@ -319,7 +319,7 @@ Uploads are deduplicated by SHA-256 content hash. A reuse hit requires the SAME (see cache-key below); identical bytes uploaded under a different filename or a different key produce separate cache rows: -- Cache file: `~/.claude/cache/claude-delegator/grok-files.json` +- Cache file: `~/.claude/cache/deliberation/grok-files.json` - Cache key: `sha256(bytes)@sha256(XAI_API_KEY)[:16]@normalize(apiBase)@effectiveFilename` - Key rotation auto-invalidates entries (different `keyFp`). - Different `apiBase` (including port/protocol differences) → separate rows. @@ -338,7 +338,7 @@ key produce separate cache rows: known file id are surfaced unchanged. - `XAI_DISABLE_FILE_CACHE=1` (env) skips the cache layer entirely (debugging). -Stored upload filenames are `claude-delegator-{sha256[:16]}-{basename}`. Uploads also +Stored upload filenames are `deliberation-{sha256[:16]}-{basename}`. Uploads also carry `expires_after` set by `GROK_FILE_TTL_SECONDS` (default `604800` = 7 days, clamped 1h..30d). @@ -346,7 +346,7 @@ clamped 1h..30d). The bundled `server/grok/files-admin.js` supports three subcommands: -- `list` - shows total xAI file count and every `claude-delegator-*` upload. +- `list` - shows total xAI file count and every `deliberation-*` upload. - `prune --older-than <30m|24h|7d|seconds> [--yes]` - dry run by default; deletes **remote** bridge-owned files matched by filename prefix + age. Works without the local cache; safe for environments where the cache was lost or never existed. @@ -358,7 +358,7 @@ The bundled `server/grok/files-admin.js` supports three subcommands: files). `--force-local-prune` drops ambiguous foreign rows anyway. `prune` and `gc` are complementary: `prune` is the remote-side cleaner; `gc` keeps -the local cache aligned with remote state. The `claude-delegator-` filename prefix +the local cache aligned with remote state. The `deliberation-` filename prefix is a hard safety invariant on both paths - your own xAI files are never touched. ## OpenRouter bridge @@ -370,8 +370,8 @@ It is **advisory-only** - it cannot edit files or run shell commands. ### Configuration file The bridge and the fan-out commands (`/ask-all`, `/consensus`) read -`~/.claude/claude-delegator/config.json` at call time. Override the path with -`CLAUDE_DELEGATOR_CONFIG`. The file is stat-gated: the bridge re-reads it only when +`~/.claude/deliberation/config.json` at call time. Override the path with +`DELIBERATION_CONFIG`. The file is stat-gated: the bridge re-reads it only when the mtime changes, so edits to the `openrouter` block (models, flags, defaults) take effect immediately without restarting Claude Code or re-running `/setup`. Toggling a **built-in** provider (codex / gemini / grok) still requires `/setup` to re-register @@ -440,7 +440,7 @@ rounds; you want the models reasoning, not improvising. for the requested expert; capped to `maxFanout` models (default 3). - **`/consensus`**: includes models where `consensus === true`; NOT subject to `maxFanout`. A warning is logged when more than 3 models enter a consensus round (cost). -- **`openrouter-default`** is the reserved alias for the bare `mcp__openrouter__openrouter` +- **`openrouter-default`** is the reserved alias for the bare `mcp__deliberation__openrouter` call and `/ask-openrouter` with no alias specified. It is the single-shot fallback only and is never included in fan-out or consensus. - Implementation tasks always route to Codex or Gemini, never to OpenRouter. @@ -454,7 +454,7 @@ valid delegate and collects the bad ones into `invalidModels`. Only **top-level/ problems hard-fail the whole config: malformed JSON, a non-object root, an unsupported `version`, or a non-integer/`< 1` `maxFanout`. -`mcp__openrouter__openrouter-list` returns: +`mcp__deliberation__openrouter-list` returns: ```jsonc { @@ -511,7 +511,7 @@ Exceeding either cap returns a hard error with counts. ### Session model persistence -A model alias is bound at the start of a session via `mcp__openrouter__openrouter` +A model alias is bound at the start of a session via `mcp__deliberation__openrouter` and is preserved for the life of that `threadId`. `-reply` calls on the same thread always use the same model. @@ -525,9 +525,9 @@ token count. There is no hard spend cap - the warning is informational only. | Tool | Purpose | |------|---------| -| `mcp__openrouter__openrouter` | Start a new advisory session | -| `mcp__openrouter__openrouter-reply` | Continue a session (multi-turn via threadId) | -| `mcp__openrouter__openrouter-list` | List configured model aliases and their eligibility flags | +| `mcp__deliberation__openrouter` | Start a new advisory session | +| `mcp__deliberation__openrouter-reply` | Continue a session (multi-turn via threadId) | +| `mcp__deliberation__openrouter-list` | List configured model aliases and their eligibility flags | ### Error kinds diff --git a/assets/consensus-flow-simple.html b/assets/consensus-flow-simple.html index 0740751..47fa973 100644 --- a/assets/consensus-flow-simple.html +++ b/assets/consensus-flow-simple.html @@ -2,7 +2,7 @@ - How /consensus works (simple) — claude-delegator + How /consensus works (simple) - deliberation