Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions docs/capability-planner.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions tools/capability-planner.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
139 changes: 139 additions & 0 deletions tools/capability-planner.ts
Original file line number Diff line number Diff line change
@@ -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<ReadOnlyRequirement, number> = {
"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<Client>();
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);
}
}