From 27d1fcaeb67718e56d59e22dc41db5532b37e549 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sat, 5 Sep 2026 13:56:05 +0530 Subject: [PATCH] feat: add fail-closed client capability planning --- README.md | 2 + docs/capability-planner.md | 22 +++++ package.json | 3 +- tools/capability-planner.test.ts | 42 ++++++++++ tools/capability-planner.ts | 139 +++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 docs/capability-planner.md create mode 100644 tools/capability-planner.test.ts create mode 100644 tools/capability-planner.ts diff --git a/README.md b/README.md index 23a9403..4ca6d1a 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,8 @@ Contributor workflow and trust-boundary rules are documented in [CONTRIBUTING.md Run `bun run doctor -- --workspace /absolute/project` for a non-mutating, machine-readable compatibility report across all supported clients. The semantic doctor parses native role ids, requested models/effort, and advisor controls, catches marker-only or weakened managed files, and explicitly separates those observed declarations from live-host claims it cannot prove. +Before choosing a host, run `bun run plan -- --require-effort --minimum-readonly tool-allowlist --scope project` for a read-only capability negotiation report. Unlike a path-only portability matrix, the [planner](docs/capability-planner.md) refuses clients that would silently weaken the requested effort or advisor-isolation contract. + ### Renaming or forking Several values are *derived* from the product name, including the pinned tool-surface digest and the byte-exact legacy migration fingerprints. A manual find-and-replace will silently break them, so use the guarded tool, which recomputes every derived digest and prints a dry run first: diff --git a/docs/capability-planner.md b/docs/capability-planner.md new file mode 100644 index 0000000..01e4122 --- /dev/null +++ b/docs/capability-planner.md @@ -0,0 +1,22 @@ +# Capability negotiation planner + +Cross-client portability is not just a path-conversion problem. A role that asks for a +per-agent effort and an OS read-only sandbox cannot be represented honestly by every +host. The planner evaluates that contract before any preference is saved or file is +written. + +```bash +bun run plan -- --require-effort --minimum-readonly tool-allowlist --scope project +``` + +Use `--client` more than once to compare a subset. `--minimum-readonly` accepts, from +weakest to strongest, `prompt-only`, `frontmatter-flag`, `tool-allowlist`, and +`os-sandbox`. + +The versioned JSON result separates compatible clients from refused clients and gives a +machine-readable loss for every refusal. `bestFit` is only the strongest static match, +with deterministic registry order as a tie-breaker. It is not a model recommendation and +does not claim the live host honored the adapter. + +The command is read-only and performs no network access. It is intended for setup UIs, +CI policy checks, and migration planning before an operator commits to a client. diff --git a/package.json b/package.json index bd337d9..991a1bf 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,9 @@ "bench:registry": "bun tools/bench-tool-registry.ts", "cursor:local": "bun tools/cursor-local.ts", "doctor": "bun tools/compatibility-doctor.ts", + "plan": "bun tools/capability-planner.ts", "tools:review": "bun tools/tool-surface-review.ts --baseline tools/schema/tools.policy.json", - "test": "bun test plugins/orrery/mcp/server.test.ts plugins/orrery/mcp/tool-registry.test.ts tools/cursor-local.test.ts tools/compatibility-doctor.test.ts tools/tool-surface-review.test.ts" + "test": "bun test plugins/orrery/mcp/server.test.ts plugins/orrery/mcp/tool-registry.test.ts tools/cursor-local.test.ts tools/compatibility-doctor.test.ts tools/capability-planner.test.ts tools/tool-surface-review.test.ts" }, "devDependencies": { "@types/bun": "1.4.1", diff --git a/tools/capability-planner.test.ts b/tools/capability-planner.test.ts new file mode 100644 index 0000000..f3f21b5 --- /dev/null +++ b/tools/capability-planner.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { planCapabilities } from "./capability-planner"; + +describe("capability planner", () => { + test("returns every client for the weakest portable contract", () => { + const plan = planCapabilities({ requireEffort: false, minimumReadOnly: "prompt-only", scope: "project" }); + expect(plan.compatible).toHaveLength(6); + expect(plan.bestFit).toBe("codex"); + expect(plan.refused).toEqual([]); + }); + + test("fails closed when both effort and OS isolation are required", () => { + const plan = planCapabilities({ requireEffort: true, minimumReadOnly: "os-sandbox", scope: "project" }); + expect(plan.compatible).toEqual(["codex"]); + expect(plan.refused).toHaveLength(5); + expect(plan.decisions.find((item) => item.client === "claude-code")?.losses.map((loss) => loss.code)).toEqual([ + "effort-unbound", + "readonly-too-weak", + ]); + }); + + test("distinguishes tool allowlists from flags and prompt-only requests", () => { + const plan = planCapabilities({ + requireEffort: false, + minimumReadOnly: "tool-allowlist", + scope: "project", + }); + expect(plan.compatible).toEqual(["codex", "claude-code"]); + expect(plan.refused).toEqual(["cursor", "vscode", "github-copilot", "kiro"]); + }); + + test("keeps selection deterministic and reports user adapter directories", () => { + const plan = planCapabilities({ + requireEffort: false, + minimumReadOnly: "prompt-only", + scope: "user", + clients: ["kiro", "codex", "kiro"], + }); + expect(plan.decisions.map((item) => item.client)).toEqual(["codex", "kiro"]); + expect(plan.decisions.map((item) => item.adapterDirectory)).toEqual([".codex/agents", ".kiro/agents"]); + }); +}); diff --git a/tools/capability-planner.ts b/tools/capability-planner.ts new file mode 100644 index 0000000..c10ec5e --- /dev/null +++ b/tools/capability-planner.ts @@ -0,0 +1,139 @@ +#!/usr/bin/env bun +import { CLIENT_PROFILES, CLIENTS, type Client, type Scope } from "../plugins/orrery/mcp/server"; + +export type ReadOnlyRequirement = "prompt-only" | "frontmatter-flag" | "tool-allowlist" | "os-sandbox"; + +export type CapabilityRequirements = { + requireEffort: boolean; + minimumReadOnly: ReadOnlyRequirement; + scope: Scope; + clients?: readonly Client[]; +}; + +export type CapabilityDecision = { + client: Client; + eligible: boolean; + bindsEffort: boolean; + readOnlyMechanism: ReadOnlyRequirement; + adapterDirectory: string; + losses: Array<{ code: "effort-unbound" | "readonly-too-weak"; detail: string }>; + warnings: string[]; +}; + +export type CapabilityPlan = { + schema: 1; + requirements: CapabilityRequirements; + bestFit: Client | null; + compatible: Client[]; + refused: Client[]; + decisions: CapabilityDecision[]; + note: string; +}; + +const strength: Record = { + "prompt-only": 0, + "frontmatter-flag": 1, + "tool-allowlist": 2, + "os-sandbox": 3, +}; + +function selectedClients(clients: readonly Client[] | undefined): Client[] { + if (!clients) return [...CLIENTS]; + const unique = new Set(); + for (const client of clients) { + if (!(CLIENTS as readonly string[]).includes(client)) throw new Error(`unsupported client: ${client}`); + unique.add(client); + } + if (!unique.size) throw new Error("at least one client must be selected"); + return CLIENTS.filter((client) => unique.has(client)); +} + +export function planCapabilities(requirements: CapabilityRequirements): CapabilityPlan { + if (!(requirements.minimumReadOnly in strength)) { + throw new Error(`unsupported read-only requirement: ${requirements.minimumReadOnly}`); + } + if (requirements.scope !== "project" && requirements.scope !== "user") { + throw new Error("scope must be project or user"); + } + + const decisions = selectedClients(requirements.clients).map((client): CapabilityDecision => { + const profile = CLIENT_PROFILES[client]; + const losses: CapabilityDecision["losses"] = []; + if (requirements.requireEffort && !profile.bindsEffort) { + losses.push({ + code: "effort-unbound", + detail: `${client} cannot bind reasoning effort per agent`, + }); + } + if (strength[profile.readOnly] < strength[requirements.minimumReadOnly]) { + losses.push({ + code: "readonly-too-weak", + detail: `${client} offers ${profile.readOnly}; ${requirements.minimumReadOnly} or stronger was required`, + }); + } + const segments = requirements.scope === "project" ? profile.projectDir : profile.userDir; + return { + client, + eligible: losses.length === 0, + bindsEffort: profile.bindsEffort, + readOnlyMechanism: profile.readOnly, + adapterDirectory: segments.join("/"), + losses, + warnings: [...profile.warnings], + }; + }); + + const compatible = decisions + .filter((decision) => decision.eligible) + .sort((left, right) => { + const readonly = strength[right.readOnlyMechanism] - strength[left.readOnlyMechanism]; + if (readonly) return readonly; + const effort = Number(right.bindsEffort) - Number(left.bindsEffort); + if (effort) return effort; + return CLIENTS.indexOf(left.client) - CLIENTS.indexOf(right.client); + }) + .map((decision) => decision.client); + + return { + schema: 1, + requirements: { ...requirements, ...(requirements.clients ? { clients: [...requirements.clients] } : {}) }, + bestFit: compatible[0] ?? null, + compatible, + refused: decisions.filter((decision) => !decision.eligible).map((decision) => decision.client), + decisions, + note: + "This is static capability negotiation. A compatible adapter is still a request to the host; only live observation proves the model, effort, and isolation actually used.", + }; +} + +function parseCli(argv: string[]): CapabilityRequirements { + const allowed = new Set(["--require-effort", "--minimum-readonly", "--scope", "--client"]); + const clients: Client[] = []; + let requireEffort = false; + let minimumReadOnly: ReadOnlyRequirement = "prompt-only"; + let scope: Scope = "project"; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]!; + if (!allowed.has(flag)) throw new Error(`unknown argument: ${flag}`); + if (flag === "--require-effort") { + requireEffort = true; + continue; + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`); + index += 1; + if (flag === "--minimum-readonly") minimumReadOnly = value as ReadOnlyRequirement; + else if (flag === "--scope") scope = value as Scope; + else clients.push(value as Client); + } + return { requireEffort, minimumReadOnly, scope, ...(clients.length ? { clients } : {}) }; +} + +if (import.meta.main) { + try { + console.log(JSON.stringify(planCapabilities(parseCli(process.argv.slice(2))), null, 2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } +}