From 09781e20bb873f096bc27d68284cf5d5f44ef51f Mon Sep 17 00:00:00 2001 From: Elia <83713217+eliahilse@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:30:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20kyora=20council=20=E2=80=94=20summon=20?= =?UTF-8?q?other=20model=20families=20over=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP server exposing council_convene (sync + async), council_ask, council_task (read or write), council_result, and council_status, backed by the review engine table so every member runs as its vendor's own CLI on the user's subscription. Seating is quota-aware: highest remaining quota first, never an engine that is cooling down or spent. Adds an optional PostToolUse hook that watches the agent with a cheap model and nudges it to consult the council at high-stakes moments — keyword pre-filter, rate-limited classification and nudges, silent on every failure path so it can never block work. Engines gain write-capable invocations for delegated tasks; the review package now exports its engine, usage, and extract modules. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CQy3ZJ5MyUxo4qjZwA93Na --- bun.lock | 24 +++ packages/council/mcp/README.md | 57 ++++++ packages/council/mcp/package.json | 42 +++++ packages/council/mcp/src/council.ts | 233 ++++++++++++++++++++++++ packages/council/mcp/src/hook.ts | 105 +++++++++++ packages/council/mcp/src/index.ts | 171 +++++++++++++++++ packages/council/mcp/src/stakes.test.ts | 38 ++++ packages/council/mcp/src/stakes.ts | 99 ++++++++++ packages/council/mcp/tsconfig.json | 11 ++ packages/review/cli/package.json | 8 + packages/review/cli/src/engines.ts | 32 +++- 11 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 packages/council/mcp/README.md create mode 100644 packages/council/mcp/package.json create mode 100644 packages/council/mcp/src/council.ts create mode 100644 packages/council/mcp/src/hook.ts create mode 100644 packages/council/mcp/src/index.ts create mode 100644 packages/council/mcp/src/stakes.test.ts create mode 100644 packages/council/mcp/src/stakes.ts create mode 100644 packages/council/mcp/tsconfig.json diff --git a/bun.lock b/bun.lock index 2b0ff6b..bd02a43 100644 --- a/bun.lock +++ b/bun.lock @@ -73,6 +73,24 @@ "wrangler": "^3.99.0", }, }, + "packages/council/mcp": { + "name": "@kyora-sh/council", + "version": "0.1.0", + "bin": { + "kyora-council": "./dist/index.js", + "kyora-stakes-hook": "./dist/hook.js", + }, + "dependencies": { + "@kyora-sh/review": "workspace:*", + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/bun": "latest", + "typescript": "^5.9.2", + }, + }, "packages/review/cli": { "name": "@kyora-sh/review", "version": "0.1.0", @@ -519,6 +537,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kyora-sh/council": ["@kyora-sh/council@workspace:packages/council/mcp"], + "@kyora-sh/mcp": ["@kyora-sh/mcp@workspace:packages/state/mcp"], "@kyora-sh/review": ["@kyora-sh/review@workspace:packages/review/cli"], @@ -2265,6 +2285,10 @@ "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@kyora-sh/council/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@kyora-sh/review/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@kyora/docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@kyora/web/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], diff --git a/packages/council/mcp/README.md b/packages/council/mcp/README.md new file mode 100644 index 0000000..e40d43f --- /dev/null +++ b/packages/council/mcp/README.md @@ -0,0 +1,57 @@ +# @kyora-sh/council + +Summon councils of other model families from inside your coding agent. + +Your agent is one lineage with one set of blind spots. This gives it a way to ask others — Codex, Claude, GLM, Grok, Kimi, Qwen — before it commits to something expensive to undo, all on the subscriptions you already pay for, all running locally as each vendor's own CLI. + +## Tools + +| tool | what it does | +| --- | --- | +| `council_convene` | same question to several model families at once, independent takes back | +| `council_convene_async` | same, in the background — collect later with `council_result` | +| `council_ask` | one specific family's perspective, read-only | +| `council_task` | delegate concrete work to one family (`write: true` lets it edit files) | +| `council_result` | collect a background council or task by job id | +| `council_status` | who can be summoned right now, with remaining quota | + +Councils are quota-aware: members are seated highest-remaining-quota first, engines that are cooling down or exhausted are never summoned, and `size` caps a council so one question doesn't spend every subscription. + +## Install + +```json +{ + "mcpServers": { + "kyora-council": { "command": "bunx", "args": ["@kyora-sh/council"] } + } +} +``` + +Engine auth is shared with [`@kyora-sh/review`](../../review/cli) — run `kyora-review doctor` to see who is ready. + +## High-stakes watcher (optional) + +A `PostToolUse` hook that watches your agent's work with a cheap model and reminds it to consult the council at genuinely consequential moments — schema migrations, auth changes, force pushes, irreversible operations. + +```json +{ + "hooks": { + "PostToolUse": [ + { "matcher": "Write|Edit|MultiEdit|Bash", + "hooks": [{ "type": "command", "command": "bunx @kyora-sh/council kyora-stakes-hook" }] } + ] + } +} +``` + +It is deliberately cheap and quiet: a keyword/size pre-filter runs first so most tool calls never reach a model at all, classifications are rate-limited (`KYORA_WATCHER_MIN_GAP_MS`, default 2m), nudges are rate-limited harder (`KYORA_WATCHER_NUDGE_GAP_MS`, default 15m), and every failure path stays silent so your agent is never blocked. + +| env | default | +| --- | --- | +| `KYORA_WATCHER_KEY` | falls back to `ZAI_API_KEY` / `QWEN_API_KEY` | +| `KYORA_WATCHER_MODEL` | `glm-4.5-air` (z.ai) or `qwen3.6-plus` (Bailian) | +| `KYORA_WATCHER_BASE_URL` | inferred from which key is present | + +Any OpenAI-compatible endpoint works — point it at whatever cheap, fast model you like. + +Part of [kyora](https://kyora.sh) · Elastic-2.0 diff --git a/packages/council/mcp/package.json b/packages/council/mcp/package.json new file mode 100644 index 0000000..be16c7e --- /dev/null +++ b/packages/council/mcp/package.json @@ -0,0 +1,42 @@ +{ + "name": "@kyora-sh/council", + "version": "0.1.0", + "type": "module", + "description": "Summon councils of other model families from inside your coding agent — fan out a decision to Codex, Claude, GLM, Grok, Kimi and Qwen, or ask/delegate to one, over MCP", + "license": "Elastic-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eliahilse/kyora", + "directory": "packages/council/mcp" + }, + "homepage": "https://kyora.sh", + "main": "./dist/index.js", + "bin": { + "kyora-council": "./dist/index.js", + "kyora-stakes-hook": "./dist/hook.js" + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target bun --minify && bun build ./src/hook.ts --outdir ./dist --target bun --minify && for f in index hook; do printf '#!/usr/bin/env bun\\n' | cat - ./dist/$f.js > ./dist/$f.tmp && mv ./dist/$f.tmp ./dist/$f.js && chmod +x ./dist/$f.js; done", + "check-types": "tsc --noEmit", + "test": "bun test", + "dev": "bun --watch src/index.ts", + "prepublishOnly": "bun run build" + }, + "dependencies": { + "@kyora-sh/review": "workspace:*", + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/bun": "latest", + "typescript": "^5.9.2" + } +} diff --git a/packages/council/mcp/src/council.ts b/packages/council/mcp/src/council.ts new file mode 100644 index 0000000..63cf0bb --- /dev/null +++ b/packages/council/mcp/src/council.ts @@ -0,0 +1,233 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { + ENGINES, + engineById, + engineStatus, + runEngineRaw, + type EngineDef, + type EngineMode, +} from "@kyora-sh/review/engines" +import { cooldownRemainingMs, lastRunAt, loadUsage } from "@kyora-sh/review/usage" +import type { ReviewConfig } from "@kyora-sh/review/types" + +export const RUN_CONFIG: ReviewConfig = { + engines: ["auto"], + verify: false, + post: false, + base: "main", + failOn: "none", + maxDiffBytes: 100_000, + timeoutMs: Number(process.env.KYORA_COUNCIL_TIMEOUT_MS ?? 600_000), + maxFindingsPerEngine: 20, + cooldownMinutes: 60, + maxEngines: 0, + overrides: {}, +} + +export interface EngineHealth { + id: string + label: string + available: boolean + reason: string + cooldownMinutes: number + remainingPct: number | null + writeCapable: boolean +} + +export async function councilStatus(): Promise { + const usage = loadUsage() + return Promise.all( + ENGINES.map(async (engine) => { + const status = engineStatus(engine, undefined) + const live = status.available && engine.usageProbe ? await engine.usageProbe() : null + return { + id: engine.id, + label: engine.label, + available: status.available, + reason: status.reason, + cooldownMinutes: Math.ceil(cooldownRemainingMs(engine.id, usage) / 60_000), + remainingPct: live?.remainingPct ?? null, + writeCapable: Boolean(engine.argsWrite), + } + }), + ) +} + +/** Engines that can actually be spent right now, cheapest-to-quota first. */ +export async function healthyEngines(requested?: string[], mode: EngineMode = "read"): Promise { + const usage = loadUsage() + const pool = requested?.length + ? requested.map((id) => engineById(id.trim())).filter((engine): engine is EngineDef => Boolean(engine)) + : ENGINES + const checked = await Promise.all( + pool.map(async (engine) => { + if (!engineStatus(engine, undefined).available) return null + if (mode === "write" && !engine.argsWrite) return null + if (cooldownRemainingMs(engine.id, usage) > 0) return null + const live = engine.usageProbe ? await engine.usageProbe() : null + if (live !== null && live.remainingPct <= 0) return null + return { engine, headroom: live?.remainingPct ?? 50 } + }), + ) + return checked + .filter((item): item is { engine: EngineDef; headroom: number } => item !== null) + .sort((a, b) => b.headroom - a.headroom || lastRunAt(a.engine.id, usage) - lastRunAt(b.engine.id, usage)) + .map((item) => item.engine) +} + +const ASK_PROMPT = (question: string, context: string | undefined) => + `You are consulted as an independent expert from a different model family than the agent asking. Give your own honest assessment — do not defer to the framing of the question, and say so plainly if you think the premise is wrong. + +You are inside the repository checkout and may read files and run small read-only probes to ground your answer. Do not modify files. Do not run the project's test suites or builds. + +Be concise and concrete: lead with your position, then the reasoning that would change someone's mind. If you are uncertain, say what would resolve the uncertainty. +${context ? `\nCONTEXT FROM THE AGENT:\n${context}\n` : ""} +QUESTION: +${question}` + +const TASK_PROMPT = (task: string, context: string | undefined, write: boolean) => + `You are delegated a task by another coding agent. ${ + write + ? "You may edit files in this repository to complete it." + : "Work read-only: investigate and report, do not modify files." + } Do not run the project's test suites or builds — CI covers those. Do not commit, push, or install packages. + +When done, report what you did (or found) and anything the delegating agent must know — especially surprises, things you could not verify, and follow-ups you deliberately left. +${context ? `\nCONTEXT:\n${context}\n` : ""} +TASK: +${task}` + +export interface EngineReply { + engine: string + ok: boolean + text: string + durationMs: number + rateLimited?: boolean +} + +function cleanText(raw: string): string { + const trimmed = raw.trim() + try { + const parsed = JSON.parse(trimmed) + if (parsed && typeof parsed === "object") { + const obj = parsed as Record + for (const key of ["result", "last_message", "text", "message"]) { + if (typeof obj[key] === "string") return (obj[key] as string).trim() + } + if (obj.structuredOutput && typeof obj.structuredOutput === "object") { + return JSON.stringify(obj.structuredOutput, null, 2) + } + } + } catch {} + // NDJSON event streams: concatenate text parts, newest run last + const texts: string[] = [] + for (const line of trimmed.split("\n")) { + try { + const event = JSON.parse(line) as Record + const part = event.part as Record | undefined + if (event.type === "text" && part && typeof part.text === "string") texts.push(part.text) + else if (typeof event.text === "string") texts.push(event.text) + } catch {} + } + return texts.length > 0 ? texts.join("\n").trim() : trimmed +} + +export async function askEngine( + engine: EngineDef, + prompt: string, + cwd: string, + mode: EngineMode = "read", +): Promise { + const run = await runEngineRaw(engine, prompt, {}, cwd, RUN_CONFIG, mode) + return { + engine: engine.id, + ok: run.ok, + text: run.ok ? cleanText(run.raw) : (run.error ?? "failed"), + durationMs: run.durationMs, + ...(run.rateLimited ? { rateLimited: true } : {}), + } +} + +export async function convene(opts: { + question: string + context?: string + engines?: string[] + size?: number + cwd: string +}): Promise<{ replies: EngineReply[]; skipped: string[] }> { + const available = await healthyEngines(opts.engines) + const size = opts.size && opts.size > 0 ? opts.size : available.length + const seated = available.slice(0, size) + const skipped = ENGINES.filter((engine) => !seated.some((chosen) => chosen.id === engine.id)).map( + (engine) => engine.id, + ) + if (seated.length === 0) return { replies: [], skipped } + const prompt = ASK_PROMPT(opts.question, opts.context) + const replies = await Promise.all(seated.map((engine) => askEngine(engine, prompt, opts.cwd))) + return { replies, skipped } +} + +export function askPrompt(question: string, context?: string): string { + return ASK_PROMPT(question, context) +} + +export function taskPrompt(task: string, context: string | undefined, write: boolean): string { + return TASK_PROMPT(task, context, write) +} + +export interface Job { + id: string + kind: "council" | "task" + status: "running" | "done" | "failed" + question: string + engines: string[] + startedAt: number + finishedAt?: number + replies?: EngineReply[] + error?: string +} + +function jobsDir(): string { + return ( + process.env.KYORA_COUNCIL_STATE_DIR ?? + join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "kyora-council") + ) +} + +export function saveJob(job: Job): void { + try { + mkdirSync(jobsDir(), { recursive: true }) + writeFileSync(join(jobsDir(), `${job.id}.json`), JSON.stringify(job, null, 2)) + } catch {} +} + +export function loadJob(id: string): Job | null { + try { + return JSON.parse(readFileSync(join(jobsDir(), `${id.replace(/[^\w-]/g, "")}.json`), "utf8")) as Job + } catch { + return null + } +} + +export function newJobId(kind: string, seed: number): string { + return `${kind}-${seed.toString(36)}` +} + +/** Points where replies disagree matter more than where they agree. */ +export function summarizeReplies(replies: EngineReply[]): string { + const ok = replies.filter((reply) => reply.ok) + const failed = replies.filter((reply) => !reply.ok) + const lines = [ + `${ok.length} of ${replies.length} council members responded${ + failed.length > 0 ? ` (${failed.map((reply) => `${reply.engine}: ${reply.text.slice(0, 80)}`).join("; ")})` : "" + }.`, + "", + "Read the takes below as independent opinions, not votes to average. Where they agree, confidence is high. Where they disagree, that disagreement is the signal — resolve it against the code before acting.", + ] + for (const reply of ok) { + lines.push("", `### ${reply.engine} (${Math.round(reply.durationMs / 1000)}s)`, "", reply.text) + } + return lines.join("\n") +} diff --git a/packages/council/mcp/src/hook.ts b/packages/council/mcp/src/hook.ts new file mode 100644 index 0000000..795c508 --- /dev/null +++ b/packages/council/mcp/src/hook.ts @@ -0,0 +1,105 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { classify, watcherConfig, worthClassifying } from "./stakes" + +interface HookInput { + session_id?: string + transcript_path?: string + cwd?: string + tool_name?: string + tool_input?: unknown +} + +interface WatchState { + lastCheck?: number + lastNudge?: number + checks?: number +} + +function statePath(sessionId: string): string { + const dir = + process.env.KYORA_COUNCIL_STATE_DIR ?? + join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "kyora-council") + mkdirSync(dir, { recursive: true }) + return join(dir, `watch-${sessionId.replace(/[^\w-]/g, "")}.json`) +} + +function readState(path: string): WatchState { + try { + return JSON.parse(readFileSync(path, "utf8")) as WatchState + } catch { + return {} + } +} + +function writeState(path: string, state: WatchState): void { + try { + writeFileSync(path, JSON.stringify(state)) + } catch {} +} + +/** Emitting nothing lets the agent proceed untouched — the default for every failure path. */ +function pass(): never { + process.exit(0) +} + +function nudge(reason: string, suggested: string[]): never { + const who = suggested.length > 0 ? ` Suggested members: ${suggested.join(", ")}.` : "" + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: `[kyora council] This looks like a high-stakes moment: ${reason} Before committing to it, consider consulting other model families — \`council_convene\` for independent takes across lineages, or \`council_ask\` for one specific perspective. If you have already validated this, ignore this note and continue.${who}`, + }, + }), + ) + process.exit(0) +} + +const MIN_GAP_MS = Number(process.env.KYORA_WATCHER_MIN_GAP_MS ?? 120_000) +const NUDGE_GAP_MS = Number(process.env.KYORA_WATCHER_NUDGE_GAP_MS ?? 900_000) + +async function main(): Promise { + const raw = await Bun.stdin.text().catch(() => "") + if (!raw.trim()) pass() + let input: HookInput + try { + input = JSON.parse(raw) as HookInput + } catch { + pass() + } + + const toolName = input.tool_name ?? "" + const payload = JSON.stringify(input.tool_input ?? {}) + if (!worthClassifying(toolName, payload)) pass() + + const config = watcherConfig() + if (!config) pass() + + const path = statePath(input.session_id ?? "default") + const state = readState(path) + const now = Date.now() + if (state.lastCheck && now - state.lastCheck < MIN_GAP_MS) pass() + if (state.lastNudge && now - state.lastNudge < NUDGE_GAP_MS) pass() + + writeState(path, { ...state, lastCheck: now, checks: (state.checks ?? 0) + 1 }) + + let tail = "" + if (input.transcript_path) { + try { + const lines = readFileSync(input.transcript_path, "utf8").trim().split("\n") + tail = lines.slice(-12).join("\n") + } catch {} + } + const snippet = `RECENT AGENT ACTIVITY:\n${tail}\n\nCURRENT ACTION — ${toolName}:\n${payload.slice(0, 2000)}` + + const verdict = await classify(snippet, config) + if (!verdict?.highStakes) pass() + + writeState(path, { ...readState(path), lastNudge: now }) + nudge(verdict.reason, verdict.suggested) +} + +await main() +pass() diff --git a/packages/council/mcp/src/index.ts b/packages/council/mcp/src/index.ts new file mode 100644 index 0000000..f29f26d --- /dev/null +++ b/packages/council/mcp/src/index.ts @@ -0,0 +1,171 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +import { engineById } from "@kyora-sh/review/engines" +import { + askEngine, + askPrompt, + convene, + councilStatus, + healthyEngines, + loadJob, + newJobId, + saveJob, + summarizeReplies, + taskPrompt, + type Job, +} from "./council" + +const server = new McpServer({ name: "kyora-council", version: "0.1.0" }) + +const text = (body: string) => ({ content: [{ type: "text" as const, text: body }] }) + +function cwdOf(dir?: string): string { + return dir ?? process.env.KYORA_COUNCIL_CWD ?? process.cwd() +} + +server.tool( + "council_convene", + "Ask several other model families the same question at once and get their independent takes. Use before high-stakes or irreversible decisions: architecture choices, security-sensitive changes, tricky debugging conclusions, or when you are about to commit to an approach you cannot cheaply undo.", + { + question: z.string().describe("the decision or question, stated so a model with no prior context can judge it"), + context: z.string().optional().describe("your current reasoning, constraints, and what you are about to do"), + engines: z.array(z.string()).optional().describe("engine ids to seat (default: all with quota)"), + size: z.number().optional().describe("cap the council to N members, highest remaining quota first"), + cwd: z.string().optional().describe("repository path the council should reason inside"), + }, + async ({ question, context, engines, size, cwd }) => { + const result = await convene({ question, context, engines, size, cwd: cwdOf(cwd) }) + if (result.replies.length === 0) { + return text( + `No council member could be seated — every engine is unavailable, cooling down, or out of quota. Skipped: ${result.skipped.join(", ")}. Proceed on your own judgment, and say so.`, + ) + } + return text(summarizeReplies(result.replies)) + }, +) + +server.tool( + "council_convene_async", + "Start a council in the background and return a job id immediately. Use when you want other model families deliberating while you keep working; collect the verdict later with council_result.", + { + question: z.string(), + context: z.string().optional(), + engines: z.array(z.string()).optional(), + size: z.number().optional(), + cwd: z.string().optional(), + }, + async ({ question, context, engines, size, cwd }) => { + const started = Date.now() + const id = newJobId("council", started) + const seated = await healthyEngines(engines) + const job: Job = { + id, + kind: "council", + status: "running", + question, + engines: seated.slice(0, size && size > 0 ? size : seated.length).map((engine) => engine.id), + startedAt: started, + } + saveJob(job) + void convene({ question, context, engines, size, cwd: cwdOf(cwd) }) + .then((result) => + saveJob({ ...job, status: "done", finishedAt: Date.now(), replies: result.replies }), + ) + .catch((error: unknown) => + saveJob({ ...job, status: "failed", finishedAt: Date.now(), error: String(error) }), + ) + return text( + job.engines.length > 0 + ? `Council ${id} convened in the background with ${job.engines.join(", ")}. Collect it with council_result when you reach a natural checkpoint.` + : `Council ${id} could not seat any member (no engine has quota right now).`, + ) + }, +) + +server.tool( + "council_result", + "Collect the verdict of a background council or delegated task by job id.", + { job_id: z.string() }, + async ({ job_id }) => { + const job = loadJob(job_id) + if (!job) return text(`No job ${job_id} found.`) + if (job.status === "running") { + return text(`Job ${job_id} is still running (${Math.round((Date.now() - job.startedAt) / 1000)}s so far).`) + } + if (job.status === "failed") return text(`Job ${job_id} failed: ${job.error}`) + return text(summarizeReplies(job.replies ?? [])) + }, +) + +server.tool( + "council_ask", + "Ask one specific model family for its take, read-only. Use when a particular perspective is what you want — a second opinion from a different lineage than your own.", + { + engine: z.string().describe("engine id: codex, claude, kimi, glm, grok, qwen"), + question: z.string(), + context: z.string().optional(), + cwd: z.string().optional(), + }, + async ({ engine, question, context, cwd }) => { + const def = engineById(engine) + if (!def) return text(`Unknown engine "${engine}". Run council_status to see who is available.`) + const seated = await healthyEngines([engine]) + if (seated.length === 0) return text(`${engine} is unavailable, cooling down, or out of quota right now.`) + const reply = await askEngine(seated[0]!, askPrompt(question, context), cwdOf(cwd)) + return text(reply.ok ? reply.text : `${engine} failed: ${reply.text}`) + }, +) + +server.tool( + "council_task", + "Delegate a concrete piece of work to one model family. Read-only by default; pass write:true to let it edit files in the repository. Use to parallelize work or to hand a task to a lineage better suited to it.", + { + engine: z.string(), + task: z.string().describe("what to do, stated completely — the delegate has none of your conversation"), + context: z.string().optional(), + write: z.boolean().optional().describe("allow file edits (default false)"), + background: z.boolean().optional().describe("return a job id immediately instead of waiting"), + cwd: z.string().optional(), + }, + async ({ engine, task, context, write, background, cwd }) => { + const mode = write ? ("write" as const) : ("read" as const) + const seated = await healthyEngines([engine], mode) + if (seated.length === 0) { + const def = engineById(engine) + if (def && mode === "write" && !def.argsWrite) return text(`${engine} has no write-capable invocation.`) + return text(`${engine} is unavailable, cooling down, or out of quota right now.`) + } + const prompt = taskPrompt(task, context, Boolean(write)) + if (!background) { + const reply = await askEngine(seated[0]!, prompt, cwdOf(cwd), mode) + return text(reply.ok ? reply.text : `${engine} failed: ${reply.text}`) + } + const started = Date.now() + const id = newJobId("task", started) + const job: Job = { id, kind: "task", status: "running", question: task, engines: [engine], startedAt: started } + saveJob(job) + void askEngine(seated[0]!, prompt, cwdOf(cwd), mode) + .then((reply) => saveJob({ ...job, status: "done", finishedAt: Date.now(), replies: [reply] })) + .catch((error: unknown) => saveJob({ ...job, status: "failed", finishedAt: Date.now(), error: String(error) })) + return text(`Task ${id} delegated to ${engine} in the background. Collect it with council_result.`) + }, +) + +server.tool( + "council_status", + "Show which model families can be summoned right now, with remaining subscription quota where the vendor exposes it.", + {}, + async () => { + const health = await councilStatus() + const lines = health.map((engine) => { + const quota = engine.remainingPct === null ? "" : ` · ${engine.remainingPct}% quota left` + const cooling = engine.cooldownMinutes > 0 ? ` · cooling down ${engine.cooldownMinutes}m` : "" + const write = engine.writeCapable ? "" : " · read-only" + return `${engine.available ? "✓" : "✗"} ${engine.id.padEnd(6)} ${engine.available ? "ready" : engine.reason}${quota}${cooling}${write}` + }) + return text(lines.join("\n")) + }, +) + +await server.connect(new StdioServerTransport()) diff --git a/packages/council/mcp/src/stakes.test.ts b/packages/council/mcp/src/stakes.test.ts new file mode 100644 index 0000000..f46673b --- /dev/null +++ b/packages/council/mcp/src/stakes.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { parseVerdict, worthClassifying } from "./stakes" + +describe("worthClassifying", () => { + test("ignores read-only and trivial tools", () => { + expect(worthClassifying("Read", '{"file_path":"src/auth.ts"}')).toBe(false) + expect(worthClassifying("Grep", '{"pattern":"password"}')).toBe(false) + expect(worthClassifying("Edit", '{"file_path":"README.md","new_string":"typo fix"}')).toBe(false) + }) + + test("flags sensitive edits and consequential commands", () => { + expect(worthClassifying("Edit", '{"file_path":"src/auth/session.ts","new_string":"token"}')).toBe(true) + expect(worthClassifying("Write", '{"file_path":"migrations/001.sql","content":"DROP TABLE users"}')).toBe(true) + expect(worthClassifying("Bash", '{"command":"git push --force origin main"}')).toBe(true) + expect(worthClassifying("Bash", '{"command":"terraform apply"}')).toBe(true) + }) + + test("flags very large edits even without keywords", () => { + expect(worthClassifying("Write", `{"content":"${"x".repeat(1600)}"}`)).toBe(true) + expect(worthClassifying("Bash", '{"command":"ls -la"}')).toBe(false) + }) +}) + +describe("parseVerdict", () => { + test("parses a verdict embedded in prose", () => { + const verdict = parseVerdict('Sure: {"highStakes": true, "reason": "Dropping a table.", "suggested": ["codex"]}') + expect(verdict).toEqual({ highStakes: true, reason: "Dropping a table.", suggested: ["codex"] }) + }) + + test("rejects malformed or non-verdict output", () => { + expect(parseVerdict("no json here")).toBeNull() + expect(parseVerdict('{"reason":"missing the boolean"}')).toBeNull() + }) + + test("tolerates missing optional fields", () => { + expect(parseVerdict('{"highStakes": false}')).toEqual({ highStakes: false, reason: "", suggested: [] }) + }) +}) diff --git a/packages/council/mcp/src/stakes.ts b/packages/council/mcp/src/stakes.ts new file mode 100644 index 0000000..2d1b041 --- /dev/null +++ b/packages/council/mcp/src/stakes.ts @@ -0,0 +1,99 @@ +export interface StakesVerdict { + highStakes: boolean + reason: string + suggested: string[] +} + +export interface TranscriptEntry { + role?: string + type?: string + content?: unknown +} + +/** + * Cheap pre-filter before spending a model call: only tool activity that could + * plausibly be consequential is worth classifying. + */ +const INTERESTING = /\b(migration|migrate|schema|auth|token|secret|credential|password|encrypt|permission|delete|drop|truncate|deploy|release|payment|billing|rm -rf|force|revoke|cascade)\b/i + +const CONSEQUENTIAL_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"]) + +export function worthClassifying(toolName: string, payload: string): boolean { + if (!CONSEQUENTIAL_TOOLS.has(toolName)) return false + if (toolName === "Bash") { + return INTERESTING.test(payload) || /\b(git (push|reset|rebase)|kubectl|terraform|docker|psql|redis-cli)\b/.test(payload) + } + return INTERESTING.test(payload) || payload.length > 1500 +} + +export const CLASSIFIER_PROMPT = `You watch a coding agent work and decide whether it is at a genuinely high-stakes moment — one where a second opinion from another model family would be worth the cost. + +HIGH STAKES means: an architectural commitment that will be expensive to reverse; a security- or data-integrity-sensitive change (auth, secrets, permissions, migrations, deletion); an irreversible or production-affecting operation; or a debugging conclusion the agent is about to act on that rests on an unverified assumption. + +NOT high stakes: routine edits, tests, refactors, formatting, docs, exploration, or anything trivially revertible. Most moments are NOT high stakes — say so. False alarms cost the user real money. + +Respond with ONLY JSON: {"highStakes": boolean, "reason": "", "suggested": [""]}` + +export function parseVerdict(raw: string): StakesVerdict | null { + const match = /\{[\s\S]*\}/.exec(raw) + if (!match) return null + try { + const parsed = JSON.parse(match[0]) as Record + if (typeof parsed.highStakes !== "boolean") return null + return { + highStakes: parsed.highStakes, + reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 400) : "", + suggested: Array.isArray(parsed.suggested) + ? parsed.suggested.filter((item): item is string => typeof item === "string").slice(0, 4) + : [], + } + } catch { + return null + } +} + +export interface WatcherConfig { + baseUrl: string + apiKey: string + model: string +} + +export function watcherConfig(): WatcherConfig | null { + const apiKey = process.env.KYORA_WATCHER_KEY ?? process.env.ZAI_API_KEY ?? process.env.QWEN_API_KEY + if (!apiKey) return null + const baseUrl = + process.env.KYORA_WATCHER_BASE_URL ?? + (process.env.KYORA_WATCHER_KEY || process.env.ZAI_API_KEY + ? "https://api.z.ai/api/paas/v4" + : "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1") + const model = process.env.KYORA_WATCHER_MODEL ?? (baseUrl.includes("z.ai") ? "glm-4.5-air" : "qwen3.6-plus") + return { baseUrl, apiKey, model } +} + +/** One cheap chat completion; any failure means "not high stakes" so the hook never blocks work. */ +export async function classify(snippet: string, config: WatcherConfig): Promise { + try { + const response = await fetch(`${config.baseUrl}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${config.apiKey}` }, + body: JSON.stringify({ + model: config.model, + max_tokens: 300, + temperature: 0, + messages: [ + { role: "system", content: CLASSIFIER_PROMPT }, + { role: "user", content: snippet.slice(-6000) }, + ], + }), + signal: AbortSignal.timeout(Number(process.env.KYORA_WATCHER_TIMEOUT_MS ?? 12_000)), + }) + if (!response.ok) return null + const payload = (await response.json()) as { + choices?: { message?: { content?: string } }[] + } + const content = payload.choices?.[0]?.message?.content + return content ? parseVerdict(content) : null + } catch { + return null + } +} diff --git a/packages/council/mcp/tsconfig.json b/packages/council/mcp/tsconfig.json new file mode 100644 index 0000000..17ddc96 --- /dev/null +++ b/packages/council/mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "types": ["bun"], + "module": "ESNext", + "moduleResolution": "bundler" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/review/cli/package.json b/packages/review/cli/package.json index 3ebede6..f3ca616 100644 --- a/packages/review/cli/package.json +++ b/packages/review/cli/package.json @@ -14,8 +14,16 @@ "bin": { "kyora-review": "./dist/index.js" }, + "exports": { + ".": "./dist/index.js", + "./engines": "./src/engines.ts", + "./usage": "./src/usage.ts", + "./extract": "./src/extract.ts", + "./types": "./src/types.ts" + }, "files": [ "dist", + "src", "README.md" ], "publishConfig": { diff --git a/packages/review/cli/src/engines.ts b/packages/review/cli/src/engines.ts index 5d5d9f1..788fc3d 100644 --- a/packages/review/cli/src/engines.ts +++ b/packages/review/cli/src/engines.ts @@ -76,6 +76,8 @@ export interface EngineDef { ready?: () => string | null /** tokens {prompt} {schema} {out} are substituted; first element is replaced by the resolved bin */ args: string[] + /** args for delegated work that may edit files; absent = engine is read-only */ + argsWrite?: string[] env?: () => Record /** engine writes its final message to the {out} file instead of stdout */ readsOutFile?: boolean @@ -146,12 +148,33 @@ const CLAUDE_ARGS = [ CLAUDE_DENIED, ] +/** same CI/destructive denials, but file edits permitted for delegated work */ +const CLAUDE_WRITE_DENIED = CLAUDE_DENIED.split(",") + .filter((rule) => !["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(rule)) + .join(",") + +const CLAUDE_WRITE_ARGS = [ + "-p", + "{prompt}", + "--output-format", + "json", + "--max-turns", + "60", + "--allowedTools", + "Read,Grep,Glob,Bash,Write,Edit,MultiEdit", + "--disallowedTools", + CLAUDE_WRITE_DENIED, +] + +export type EngineMode = "read" | "write" + export const ENGINES: EngineDef[] = [ { id: "codex", label: "Codex (OpenAI)", bin: "codex", args: ["exec", "--sandbox", "read-only", "--output-schema", "{schema}", "-o", "{out}", "{prompt}"], + argsWrite: ["exec", "--sandbox", "workspace-write", "--full-auto", "{prompt}"], readsOutFile: true, authHint: "run `codex login` (ChatGPT subscription) or set OPENAI_API_KEY — CI: seed the CODEX_AUTH_JSON secret", }, @@ -160,6 +183,7 @@ export const ENGINES: EngineDef[] = [ label: "Claude Code (Anthropic)", bin: "claude", args: CLAUDE_ARGS, + argsWrite: CLAUDE_WRITE_ARGS, usageProbe: claudeUsageProbe, authHint: "log in once via `claude`, or set CLAUDE_CODE_OAUTH_TOKEN (created with `claude setup-token`)", }, @@ -175,6 +199,7 @@ export const ENGINES: EngineDef[] = [ ANTHROPIC_MODEL: process.env.KIMI_MODEL ?? "kimi-k3", ANTHROPIC_API_KEY: undefined, }), + argsWrite: CLAUDE_WRITE_ARGS, usageProbe: kimiUsageProbe, authHint: "set KIMI_API_KEY (Kimi membership / platform.kimi.ai); optional KIMI_BASE_URL, KIMI_MODEL", }, @@ -190,6 +215,7 @@ export const ENGINES: EngineDef[] = [ ANTHROPIC_MODEL: process.env.ZAI_MODEL ?? "glm-5.2", ANTHROPIC_API_KEY: undefined, }), + argsWrite: CLAUDE_WRITE_ARGS, usageProbe: glmUsageProbe, authHint: "set ZAI_API_KEY (GLM Coding Plan), or log in once via `opencode auth login` — the key is picked up from there", }, @@ -198,6 +224,7 @@ export const ENGINES: EngineDef[] = [ label: "Grok Build (xAI)", bin: "grok", args: ["--verbatim", "--reasoning-effort", "high", "--output-format", "json", "--json-schema", "{schemaJson}", "-p", "{prompt}"], + argsWrite: ["--verbatim", "--reasoning-effort", "high", "--always-approve", "-p", "{prompt}"], authHint: "log in via `grok login`, or set GROK_API_KEY / XAI_API_KEY (console.x.ai)", }, { @@ -213,6 +240,7 @@ export const ENGINES: EngineDef[] = [ ANTHROPIC_MODEL: process.env.QWEN_MODEL ?? "qwen3.8-max-preview", ANTHROPIC_API_KEY: undefined, }), + argsWrite: CLAUDE_WRITE_ARGS, usageProbe: qwenUsageProbe, authHint: "set QWEN_API_KEY (Token Plan key), or run `bl config agent` once — the key is picked up from there", }, @@ -258,6 +286,7 @@ export async function runEngineRaw( schema: unknown, cwd: string, config: ReviewConfig, + mode: EngineMode = "read", ): Promise { const override = config.overrides[engine.id] const started = Date.now() @@ -268,7 +297,8 @@ export async function runEngineRaw( await Bun.write(schemaPath, JSON.stringify(schema)) const bin = override?.bin ?? engine.bin - const argTemplate = override?.args ?? engine.args + const argTemplate = + mode === "write" ? (engine.argsWrite ?? override?.args ?? engine.args) : (override?.args ?? engine.args) const args = argTemplate.map((arg) => arg .replace("{schemaJson}", () => JSON.stringify(schema))