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
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ For environments that don't run `npm install` after fetching the plugin (Claude
- `opencode/bridge.js` — Spawns the binary with a fake HookEvent, parses the HookOutput response.
- `opencode/config.js` — Reads `open-plan-annotator.json` config for implementation handoff settings.
- `ui/` — React + Vite frontend, built to a single `build/index.html` embedded at compile time.
- `hooks/hooks.json` — Claude Code hook registration. `SessionStart` runs `scripts/install-runtime.mjs` (runtime fetch) and `scripts/session-context.mjs` (injects plan-routing instructions into Claude's session context). `PermissionRequest:ExitPlanMode` launches the annotator binary.
- `hooks/hooks.json` — Claude Code hook registration. `SessionStart` runs `scripts/install-runtime.mjs` (runtime fetch) and `scripts/session-context.mjs` (injects plan-routing instructions into Claude's session context). `PreToolUse:ExitPlanMode` launches the annotator binary.
- `skills/plan-review-triggers/SKILL.md` — Auto-loaded Claude Code skill with the full trigger heuristics. This is the long-form reference; the SessionStart context injection is the always-on nudge that keeps Claude from rationalizing past it.

## Critical Rules
Expand Down Expand Up @@ -101,10 +101,12 @@ bun run format # Format

Claude Code sends a `HookEvent` JSON on stdin with `tool_input.plan` containing the plan markdown. The binary responds on stdout with a `HookOutput` JSON:

- Approve: `{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } }`
- Deny: `{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: "..." } } }`
- Approve: `{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } }`
- Deny: `{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "..." } }`

The deny message contains serialized annotations (deletions, replacements, insertions, comments) as markdown so Claude can revise the plan.
The deny `permissionDecisionReason` contains serialized annotations (deletions, replacements, insertions, comments) as markdown so Claude can revise the plan.

The hook is registered on `PreToolUse` (not `PermissionRequest`) so it fires before the permission flow and regardless of `--permission-mode`. This makes Request Changes (`deny`) work across all hosts, including Conductor — which runs Claude Code with `--permission-prompt-tool stdio --permission-mode bypassPermissions` and never surfaces a `PermissionRequest` hook decision. (Note: in Conductor, exiting plan mode on approve is still owned by Conductor's own plan-approval UI, so an `allow` decision does not by itself leave plan mode there.)

The OpenCode bridge (`opencode/bridge.js`) constructs the same `HookEvent` format and parses the same `HookOutput` response, so the binary always goes through the same code path.

Expand Down
2 changes: 1 addition & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
]
}
],
"PermissionRequest": [
"PreToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
Expand Down
6 changes: 3 additions & 3 deletions opencode/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ export async function runPlanReview(options) {
detached: true,
});

const decision = output.hookSpecificOutput.decision;
const { permissionDecision, permissionDecisionReason } = output.hookSpecificOutput;

if (decision.behavior === "allow") {
if (permissionDecision === "allow") {
return { approved: true };
}

return {
approved: false,
feedback: decision.message,
feedback: permissionDecisionReason,
};
}
10 changes: 5 additions & 5 deletions server/historyLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ async function runSession(args: {
}

const output = JSON.parse(stdout.trim()) as {
hookSpecificOutput: { decision: { behavior: "allow" | "deny" } };
hookSpecificOutput: { permissionDecision: "allow" | "deny" };
};

return {
version: planJson.version,
history: planJson.history,
outputBehavior: output.hookSpecificOutput.decision.behavior,
outputBehavior: output.hookSpecificOutput.permissionDecision,
};
} finally {
if (child.exitCode === null) {
Expand Down Expand Up @@ -166,7 +166,7 @@ describe("stdout immediacy", () => {
session_id: "session-stdout",
cwd: "/repo",
permission_mode: "acceptEdits",
hook_event_name: "PermissionRequest",
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_use_id: "tool-stdout",
};
Expand Down Expand Up @@ -231,7 +231,7 @@ describe("stdout immediacy", () => {

// Verify it's valid hook output
const output = JSON.parse(stdout.trim());
expect(output.hookSpecificOutput.decision.behavior).toBe("allow");
expect(output.hookSpecificOutput.permissionDecision).toBe("allow");

// Clean up: kill the process (it would otherwise wait for the shutdown delay)
child.kill("SIGTERM");
Expand Down Expand Up @@ -361,7 +361,7 @@ describe("history lifecycle", () => {
session_id: "session-abc",
cwd: "/repo",
permission_mode: "acceptEdits",
hook_event_name: "PermissionRequest",
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_use_id: "tool-1",
};
Expand Down
16 changes: 10 additions & 6 deletions server/runtime/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ export function createDecisionController(): DecisionController {

export async function writeHookDecisionToStdout(decision: ServerDecision): Promise<void> {
const output: HookOutput = {
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: decision.approved
? { behavior: "allow" }
: { behavior: "deny", message: decision.feedback ?? "Plan changes requested." },
},
hookSpecificOutput: decision.approved
? {
hookEventName: "PreToolUse",
permissionDecision: "allow",
}
: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: decision.feedback ?? "Plan changes requested.",
},
};

const jsonLine = `${JSON.stringify(output)}\n`;
Expand Down
2 changes: 1 addition & 1 deletion server/runtime/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function buildDevHookEvent(): HookEvent {
transcript_path: "",
cwd: process.cwd(),
permission_mode: "default",
hook_event_name: "PermissionRequest",
hook_event_name: "PreToolUse",
tool_name: "ExitPlanMode",
tool_use_id: "dev-tool-use",
tool_input: { plan: DEV_PLAN },
Expand Down
5 changes: 3 additions & 2 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export interface UserPreferences {

export interface HookOutput {
hookSpecificOutput: {
hookEventName: "PermissionRequest";
decision: { behavior: "allow" } | { behavior: "deny"; message: string };
hookEventName: "PreToolUse";
permissionDecision: "allow" | "deny";
permissionDecisionReason?: string;
};
}

Expand Down
6 changes: 3 additions & 3 deletions shared/piExtension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ async function reviewPlan(plan, ctx) {
detached: true,
});

const decision = result.hookSpecificOutput.decision;
const { permissionDecision, permissionDecisionReason } = result.hookSpecificOutput;
return {
approved: decision.behavior === "allow",
feedback: decision.behavior === "deny" ? decision.message : undefined,
approved: permissionDecision === "allow",
feedback: permissionDecision === "deny" ? permissionDecisionReason : undefined,
};
}

Expand Down
16 changes: 9 additions & 7 deletions shared/planReview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
/**
* @typedef {{
* hookSpecificOutput: {
* hookEventName: "PermissionRequest",
* decision: { behavior: "allow" } | { behavior: "deny", message: string }
* hookEventName: "PreToolUse",
* permissionDecision: "allow" | "deny",
* permissionDecisionReason?: string
* }
* }} HookOutput
*/
Expand All @@ -24,7 +25,7 @@ export function buildHookPayload(options) {
transcript_path: "",
cwd: options.cwd ?? process.cwd(),
permission_mode: "default",
hook_event_name: "PermissionRequest",
hook_event_name: "PreToolUse",
tool_name: "ExitPlanMode",
tool_use_id: randomUUID(),
tool_input: {
Expand All @@ -43,17 +44,18 @@ export function validateHookOutput(value) {
}

const output = /** @type {HookOutput} */ (value);
const decision = output?.hookSpecificOutput?.decision;
const hookSpecificOutput = output?.hookSpecificOutput;
const permissionDecision = hookSpecificOutput?.permissionDecision;

if (!decision || typeof decision !== "object" || typeof decision.behavior !== "string") {
if (!hookSpecificOutput || typeof permissionDecision !== "string") {
throw new Error("missing decision in hook output");
}

if (decision.behavior === "allow") {
if (permissionDecision === "allow") {
return output;
}

if (decision.behavior === "deny" && typeof decision.message === "string") {
if (permissionDecision === "deny" && typeof hookSpecificOutput.permissionDecisionReason === "string") {
return output;
}

Expand Down
12 changes: 6 additions & 6 deletions shared/planReview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ describe("planReview", () => {
test("validateHookOutput accepts allow and deny decisions", () => {
expect(
validateHookOutput({
hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } },
}).hookSpecificOutput.decision.behavior,
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
}).hookSpecificOutput.permissionDecision,
).toBe("allow");

expect(
validateHookOutput({
hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: "no" } },
}).hookSpecificOutput.decision.behavior,
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "no" },
}).hookSpecificOutput.permissionDecision,
).toBe("deny");
});

test("parseHookOutput finds hook JSON in noisy stdout", () => {
const output = parseHookOutput(
'open-plan-annotator: UI available at http://localhost:1234\n{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
'open-plan-annotator: UI available at http://localhost:1234\n{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}',
"",
);

expect(output.hookSpecificOutput.decision.behavior).toBe("allow");
expect(output.hookSpecificOutput.permissionDecision).toBe("allow");
});
});
Loading