Skip to content
Open
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
11 changes: 11 additions & 0 deletions CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ node "$OPC_HARNESS" synthesize <dir> --node <id> [--run N]
# → { verdict: string, findings: object, ... }
```

### Model Routing Command

```bash
# Resolve an explicit host-native model before one subagent dispatch
node "$OPC_HARNESS" model-route --node <id> --node-type <type> [--role <name>] [--dir <project>] [--allow-premium]
# → { ok: true, dispatch: bool, tier: string, model: string|null, source: string, premium: bool, ... }
# Policy denial → { ok: false, error: { code: string, message: string, details: object } }, exit code 2
```

The resolver reads `agentRouting` through the existing user/repository layered config. `dispatch: false` means the node is orchestrator/tool-only. Hosts MUST pass a successful route's `model` through their explicit per-dispatch selector; they MUST NOT silently inherit the root model when routing fails or cannot be honored. See [`pipeline/model-routing.md`](pipeline/model-routing.md).

### Loop Commands

```bash
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ The system is built on a zero-trust axiom: **every critical output must have an
- **L2 — Design Agent Flow**: Multi-agent coordination — separation of concerns, flow topology (parallel review / sequential build), context isolation (file-based handoff, fresh agents, no session reuse).
- **L3 — Deterministic Enforcement**: The only layer that doesn't need LLM compliance — mechanical ops (severity counting, verdict rules, oscillation diff) and hardened verification (file-finding evidence checks, hedging scans, ref validation).

#### Model-aware dispatch

OPC preserves its full multi-agent topology while avoiding accidental premium-model fan-out. Before every Agent call, `opc-harness model-route` resolves an explicit host-native model from layered configuration:

| Tier | Claude Code default | Typical work |
|---|---|---|
| economy | `haiku` | test design, user/UX observers |
| standard | `sonnet` | discussion, implementation, semantic review |
| premium | `inherit` | explicit escalation only |

`inherit` and `opus` require explicit premium approval. Claude Code's `CLAUDE_CODE_SUBAGENT_MODEL` environment override is detected and reported. Other hosts can replace the tier values with their own model IDs:

```json
{
"agentRouting": {
"models": {
"economy": "your-fast-model-id",
"standard": "your-value-model-id",
"premium": "your-premium-model-id"
}
}
}
```

See [`pipeline/model-routing.md`](pipeline/model-routing.md) for node/role overrides and the resolver contract.

### Quick Start

#### Install
Expand Down Expand Up @@ -354,6 +380,20 @@ Task → Flow Selection → Node Execution → Gate Verdict → Route Next
- **L2 —— 设计 agent 流**:多 agent 协同——关注点分离、流拓扑(并行 review / 串行 build)、上下文隔离(基于文件的交接、全新 agent、不复用 session)。
- **L3 —— 确定性强制**:唯一不需要 LLM 配合的一层——机械操作(严重度计数、裁决规则、震荡 diff)和加固验证(找文件证据检查、模糊措辞扫描、引用校验)。

#### 模型感知派发

OPC 保留完整的多 agent 拓扑,同时避免顶级模型被意外放大到所有子 agent。每次调用 Agent 前,`opc-harness model-route` 都会从分层配置中解析一个明确的宿主原生模型:

| 层级 | Claude Code 默认值 | 典型工作 |
|---|---|---|
| economy | `haiku` | 测试设计、用户/UX observer |
| standard | `sonnet` | 讨论、实现、语义审查 |
| premium | `inherit` | 仅显式升级 |

`inherit` 和 `opus` 需要显式批准。Claude Code 的 `CLAUDE_CODE_SUBAGENT_MODEL` 环境变量覆盖会被检测并展示。其他宿主可以在 `agentRouting.models` 中替换成自己的模型 ID,而无需修改 OPC 的 flow。

完整的节点/角色覆盖规则见 [`pipeline/model-routing.md`](pipeline/model-routing.md)。

### 快速开始

#### 安装
Expand Down
23 changes: 17 additions & 6 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ opc-harness viz --flow {TEMPLATE}

Before starting, extract **acceptance criteria** — 3-7 concrete, testable bullet points. Evaluators grade against these.

### Agent Model Routing — Mandatory Pre-Flight

Flow topology and model tier are independent. Keep every selected node, round, and role, but before each Agent call run:

```bash
opc-harness model-route --node {NODE_ID} --node-type {NODE_TYPE} [--role {ROLE}] --dir {PROJECT_ROOT}
```

Pass the returned `model` through the host's per-dispatch selector. `dispatch: false` means no Agent. On an error or unsupported selector, stop instead of silently inheriting the root model. Re-run with `--allow-premium` only after explicit user approval. Show `💰 Model route: {tier} → {model} ({source})` before launch. Read `./pipeline/model-routing.md` only for configuration or troubleshooting.

### Quality Tier Selection — Mandatory Pre-Flight

Before the Definition of Done questions, the orchestrator MUST select a **quality tier**. See `./pipeline/quality-tiers.md` for full definitions.
Expand Down Expand Up @@ -356,8 +366,8 @@ The orchestrator searches for role definitions in this order (later sources over
Show role selection:
```
📋 Agents:
- frontend — <specific scope>
- security — <specific scope>
- frontend [standard/sonnet] — <specific scope>
- tester [economy/haiku] — <specific scope>
...

Launching {N} agents...
Expand Down Expand Up @@ -396,15 +406,15 @@ The orchestrator uses **cursor-based execution** — `flow-state.json.currentNod

Follow `./pipeline/discussion-protocol.md`.

1. Dispatch agents for 3 rounds. **Round 1: parallel** (agents are independent — no reason to serialize). Round 2: serial with context injection (each agent sees Round 1 outputs, writes diffs only). Round 3: facilitator convergence.
1. Resolve a model route for every participant, then dispatch agents for 3 rounds. **Round 1: parallel** (agents are independent — no reason to serialize). Round 2: serial with context injection (each agent sees Round 1 outputs, writes diffs only). Round 3: facilitator convergence.
2. **Orchestrator writes handshake.json** after collecting all artifacts (agents don't write it).
3. Discussion nodes produce no verdict — the decision artifact feeds downstream.

### Node Type: build

Follow `./pipeline/implementer-prompt.md` in Build/Fix/Polish mode.

1. Dispatch implementer subagent.
1. Resolve the implementer's model route and dispatch it with the returned explicit model.
2. **Single agent** → agent writes its own handshake.json.
3. **Multiple agents** (parallel, with `isolation: "worktree"`) → orchestrator merges artifacts and writes handshake.json.
4. With superpowers: invoke `superpowers:subagent-driven-development`.
Expand All @@ -414,7 +424,7 @@ Follow `./pipeline/implementer-prompt.md` in Build/Fix/Polish mode.
Follow `./pipeline/role-evaluator-prompt.md`.

1. Select roles per Role Selection rules.
2. Dispatch evaluators — parallel if no dependencies, serial with context injection if dependencies exist.
2. Resolve a model route for each selected role, then dispatch evaluators — parallel if no dependencies, serial with context injection if dependencies exist.
3. Each agent writes `eval-{role}.md` to `$SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/`.
4. **Orchestrator writes handshake.json** after all agents return, merging all eval files into artifacts[].
5. Before dispatching, build context brief using `./pipeline/context-brief.md` (for review/analysis tasks).
Expand Down Expand Up @@ -516,6 +526,7 @@ All templates live in `./pipeline/`:
- `loop-protocol.md` — **Autonomous multi-unit execution** (plan decomposition → cron loop → auto-terminate)
- `handoff-template.md` — Handshake.json specification
- `context-brief.md` — Design context brief procedure
- `model-routing.md` — Explicit per-node/per-role model selection and premium approval
- `report-format.md` — Presentation templates + JSON schema + replay
- `quality-tiers.md` — Tier definitions + baseline checklists + severity calibration
- `ux-simulation-protocol.md` — **UX simulation gate** (red flag detection, delta comparison, ordinal tier fit)
Expand Down Expand Up @@ -655,7 +666,7 @@ If hooks are not installed, the fallback behavior is: flow-state.json persists o
When the flow completes (route returns `next=null`):

1. Show final viz: `opc-harness viz --flow {template}`
2. Show summary: total steps, nodes visited, any loopbacks
2. Show summary: total steps, nodes visited, any loopbacks, and dispatched agent counts by model tier
3. **Generate HTML report** (use the session dir from init output, or find it via `opc-harness ls`):
```bash
node "$OPC_HARNESS/../opc-report.mjs" --dir <session-dir> --output <session-dir>/report.html --title "{task summary}"
Expand Down
232 changes: 232 additions & 0 deletions bin/lib/model-routing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// model-routing.mjs — deterministic model selection for every OPC subagent.
//
// Claude Code defaults an omitted subagent model to the parent model. That is
// convenient, but a multi-agent flow can accidentally multiply premium-model
// usage. This resolver keeps flow/role topology unchanged while making the
// model choice explicit, inspectable, and configurable.

import { loadLayeredOpcConfig } from "./config-layering.mjs";
import { getFlag } from "./util.mjs";

export const DEFAULT_AGENT_ROUTING = Object.freeze({
defaultTier: "standard",
unknownModelPolicy: "deny",
allowPremiumByDefault: false,
models: Object.freeze({
economy: "haiku",
standard: "sonnet",
premium: "inherit",
}),
nodeTypes: Object.freeze({
discussion: "standard",
brief: "standard",
build: "standard",
review: "standard",
execute: "none",
hotfix: "standard",
gate: "none",
}),
nodes: Object.freeze({
"test-design": "economy",
"e2e-user": "economy",
"ux-simulation": "economy",
"post-launch-sim": "economy",
}),
roles: Object.freeze({
tester: "economy",
"new-user": "economy",
"active-user": "economy",
"churned-user": "economy",
"user-simulator": "economy",
}),
premiumModels: Object.freeze(["inherit", "opus"]),
});

export class ModelRoutingError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = "ModelRoutingError";
this.code = code;
this.details = details;
}
}

function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function mergeRecord(base, override) {
return { ...base, ...(isPlainObject(override) ? override : {}) };
}

export function normalizeAgentRouting(raw = {}) {
const cfg = isPlainObject(raw) ? raw : {};
return {
...DEFAULT_AGENT_ROUTING,
...cfg,
models: mergeRecord(DEFAULT_AGENT_ROUTING.models, cfg.models),
nodeTypes: mergeRecord(DEFAULT_AGENT_ROUTING.nodeTypes, cfg.nodeTypes),
nodes: mergeRecord(DEFAULT_AGENT_ROUTING.nodes, cfg.nodes),
roles: mergeRecord(DEFAULT_AGENT_ROUTING.roles, cfg.roles),
premiumModels: Array.isArray(cfg.premiumModels)
? cfg.premiumModels.filter(v => typeof v === "string" && v.trim())
: [...DEFAULT_AGENT_ROUTING.premiumModels],
};
}

function validateIdentifier(value, label) {
if (typeof value !== "string" || !value.trim()) {
throw new ModelRoutingError("INVALID_ROUTE_INPUT", `${label} must be a non-empty string`, { [label]: value });
}
const trimmed = value.trim();
if (trimmed.length > 200 || /[\u0000-\u001f\u007f]/.test(trimmed)) {
throw new ModelRoutingError("INVALID_ROUTE_INPUT", `${label} contains invalid characters or is too long`, { [label]: value });
}
return trimmed;
}

function resolveTier(routing, { node, nodeType, role }) {
if (role && Object.hasOwn(routing.roles, role)) {
return { tier: routing.roles[role], source: `role:${role}` };
}
if (Object.hasOwn(routing.nodes, node)) {
return { tier: routing.nodes[node], source: `node:${node}` };
}
if (Object.hasOwn(routing.nodeTypes, nodeType)) {
return { tier: routing.nodeTypes[nodeType], source: `nodeType:${nodeType}` };
}
return { tier: routing.defaultTier, source: "defaultTier" };
}

function modelIsPremium(model, tier, routing) {
if (tier === "premium") return true;
const lower = model.toLowerCase();
if (lower.includes("opus")) return true;
return routing.premiumModels.some(item => item.toLowerCase() === lower);
}

/**
* Resolve a route without reading the filesystem. Useful for tests and hosts
* that already have a merged config object.
*/
export function resolveModelRoute({
node,
nodeType,
role = null,
config = {},
env = process.env,
allowPremium = false,
}) {
const safeNode = validateIdentifier(node, "node");
const safeNodeType = validateIdentifier(nodeType, "nodeType");
const safeRole = role == null ? null : validateIdentifier(role, "role");
const routing = normalizeAgentRouting(config.agentRouting);
const { tier: rawTier, source } = resolveTier(routing, {
node: safeNode,
nodeType: safeNodeType,
role: safeRole,
});
const tier = validateIdentifier(rawTier, "tier");

if (tier === "none") {
return {
ok: true,
dispatch: false,
node: safeNode,
nodeType: safeNodeType,
role: safeRole,
tier,
model: null,
source,
configSource: config._source?.agentRouting || "default",
envOverride: false,
premium: false,
premiumApproved: false,
warnings: [],
};
}

let model = routing.models[tier];
if ((model == null || model === "") && routing.unknownModelPolicy === "inherit") {
model = "inherit";
}
if (model == null || model === "") {
throw new ModelRoutingError(
"MODEL_UNRESOLVED",
`no model configured for tier '${tier}'`,
{ tier, source, unknownModelPolicy: routing.unknownModelPolicy },
);
}
model = validateIdentifier(model, "model");

const envModel = typeof env.CLAUDE_CODE_SUBAGENT_MODEL === "string"
? env.CLAUDE_CODE_SUBAGENT_MODEL.trim()
: "";
const warnings = [];
let effectiveSource = source;
let envOverride = false;
if (envModel) {
model = validateIdentifier(envModel, "CLAUDE_CODE_SUBAGENT_MODEL");
effectiveSource = "env:CLAUDE_CODE_SUBAGENT_MODEL";
envOverride = true;
warnings.push("CLAUDE_CODE_SUBAGENT_MODEL overrides OPC per-dispatch model selection");
}

const premium = modelIsPremium(model, tier, routing);
const premiumApproved = Boolean(allowPremium || routing.allowPremiumByDefault === true || envOverride);
if (premium && !premiumApproved) {
throw new ModelRoutingError(
"PREMIUM_APPROVAL_REQUIRED",
`model '${model}' requires explicit premium approval`,
{ tier, model, source: effectiveSource },
);
}

return {
ok: true,
dispatch: true,
node: safeNode,
nodeType: safeNodeType,
role: safeRole,
tier,
model,
source: effectiveSource,
configSource: config._source?.agentRouting || "default",
envOverride,
premium,
premiumApproved,
warnings,
};
}

export function cmdModelRoute(args) {
if (args.includes("--help") || args.includes("-h")) {
console.error("Usage: opc-harness model-route --node <id> --node-type <type> [--role <name>] [--dir <project>] [--allow-premium]");
console.error("Resolves the explicit host-native model for one subagent dispatch.");
return;
}

const node = getFlag(args, "node");
const nodeType = getFlag(args, "node-type");
const role = getFlag(args, "role");
const anchorDir = getFlag(args, "dir", process.cwd());
const config = loadLayeredOpcConfig(anchorDir, {});

try {
const route = resolveModelRoute({
node,
nodeType,
role,
config,
allowPremium: args.includes("--allow-premium"),
});
console.log(JSON.stringify(route, null, 2));
} catch (err) {
if (!(err instanceof ModelRoutingError)) throw err;
console.log(JSON.stringify({
ok: false,
error: { code: err.code, message: err.message, details: err.details },
}, null, 2));
process.exitCode = 2;
}
}
Loading