diff --git a/README.md b/README.md index 752e4da..03a8ecd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ What you get: - optional implementation-agent handoff after approval Restart OpenCode after installing or updating so it reloads the package-managed runtime. +OpenCode 1.x and 2.x are both supported. ### Pi diff --git a/index.js b/index.js new file mode 100644 index 0000000..e59e2f2 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +export { OpenPlanAnnotatorPluginV2 as default } from "./opencode/index.js"; diff --git a/opencode/index.js b/opencode/index.js index 3dd33b5..6dbebb6 100644 --- a/opencode/index.js +++ b/opencode/index.js @@ -71,6 +71,7 @@ const IMPLEMENTATION_PROMPT = [ "Do not call `annotate_plan` again unless the user explicitly requests re-planning.", ].join(" "); +/** @param {unknown} error */ function getErrorMessage(error) { if (error instanceof Error && error.message) { return error.message; @@ -78,6 +79,181 @@ function getErrorMessage(error) { return "unknown error"; } +/** + * @param {import("@opencode/plugin").Plugin.Context} ctx + * @param {string | undefined} implementationAgent + * @param {{ agent?: string }} event + */ +async function shouldInjectPlanReviewInstructions(ctx, implementationAgent, event) { + const currentAgent = event.agent; + if (!currentAgent) { + return true; + } + + try { + if (implementationAgent && currentAgent === implementationAgent) { + return false; + } + + const agentsResponse = await ctx.agent.list(); + const agent = agentsResponse.data.find((candidate) => candidate.name === currentAgent); + if (agent?.mode === "subagent") { + return false; + } + } catch { + return true; + } + + return true; +} + +/** + * @param {import("@opencode/plugin").Plugin.Context} ctx + * @param {string | undefined} implementationAgent + * @param {string | undefined} sessionID + */ +async function handoffToImplementationAgent(ctx, implementationAgent, sessionID) { + if (!implementationAgent) { + return null; + } + + if (!sessionID) { + return { + agent: implementationAgent, + warning: "Could not auto-switch because the current session ID was unavailable.", + }; + } + + try { + await ctx.session.switchAgent({ sessionID, agent: implementationAgent }); + return { agent: implementationAgent }; + } catch (error) { + return { + agent: implementationAgent, + warning: `Could not auto-switch to \`${implementationAgent}\`: ${getErrorMessage(error)}`, + }; + } +} + +/** @param {unknown} system */ +function getSystemText(system) { + if (!Array.isArray(system)) { + return ""; + } + return system + .map((part) => { + if (typeof part === "string") { + return part; + } + if (part && typeof part.text === "string") { + return part.text; + } + return ""; + }) + .join("\n") + .toLowerCase(); +} + +/** + * @param {import("@opencode/plugin").Plugin.Context} ctx + * @param {string | undefined} implementationAgent + * @param {{ plan: string, summary?: string }} args + * @param {{ sessionID?: string } | undefined} toolContext + * @returns {Promise} + */ +export async function executeAnnotatePlan(ctx, implementationAgent, args, toolContext) { + const result = await runPlanReview({ + plan: args.plan, + sessionId: toolContext?.sessionID, + cwd: ctx.location.directory, + }); + + const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested."); + + if (result.approved) { + const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."]; + + if (args.summary) { + lines.push(`Summary: ${args.summary}`); + } + + const handoffResult = await handoffToImplementationAgent(ctx, implementationAgent, toolContext?.sessionID); + if (handoffResult) { + if (handoffResult.warning) { + lines.push(`Auto-switch warning: ${handoffResult.warning}`); + } else { + lines.push(`Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`); + } + } + lines.push("Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan."); + lines.push("Execute the approved plan directly now — write code, create files, and make changes."); + lines.push("Do not call `annotate_plan` again unless the user explicitly requests re-planning."); + + return lines.join("\n\n"); + } + + return [ + "Plan review status: plan_status=rejected.", + "State transition: next_state=PLAN_DRAFT.", + "", + "## User feedback", + "", + feedback, + "", + "Revise the plan using this feedback, then submit the revised draft once via `annotate_plan`.", + ].join("\n"); +} + +export { PLAN_REVIEW_INSTRUCTIONS }; + +/** @type {import("@opencode/plugin").Plugin.Plugin} */ +export const OpenPlanAnnotatorPluginV2 = { + id: "open-plan-annotator", + async setup(ctx) { + const implementationHandoff = await resolveImplementationHandoff(ctx.location.directory); + const implementationAgent = implementationHandoff.enabled ? implementationHandoff.agent : undefined; + + await ctx.tool.transform((editor) => { + editor.add({ + name: "annotate_plan", + description: + "Submit a markdown plan for interactive user review. Returns plain-text execution or revision instructions for the agent.", + input: { + type: "object", + properties: { + plan: { + type: "string", + description: "The complete implementation plan in markdown format", + }, + summary: { + type: "string", + description: "Optional one-line plan summary", + }, + }, + required: ["plan"], + additionalProperties: false, + }, + async execute(input, toolContext) { + const text = await executeAnnotatePlan(ctx, implementationAgent, input, toolContext); + return { content: text }; + }, + }); + }); + + await ctx.session.hook("context", async (event) => { + if (getSystemText(event.system).includes("annotate_plan")) { + return; + } + const shouldInject = await shouldInjectPlanReviewInstructions(ctx, implementationAgent, event); + if (shouldInject) { + event.system.push({ type: "text", text: PLAN_REVIEW_INSTRUCTIONS }); + } + }); + + return () => {}; + }, +}; + /** @type {import("@opencode-ai/plugin").Plugin} */ export const OpenPlanAnnotatorPlugin = async (ctx) => { const implementationHandoff = await resolveImplementationHandoff(ctx.directory); @@ -100,14 +276,14 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => { for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i]; if (message?.info?.role === "user") { - return message?.info?.agent; + return message.info.agent; } } return undefined; } - async function shouldInjectPlanReviewInstructions(sessionID) { + async function shouldInjectInstructions(sessionID) { if (!sessionID) { return true; } @@ -125,23 +301,14 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => { const response = await ctx.client.app.agents({ query: { directory: ctx.directory }, }); - const agents = response?.data; - if (!Array.isArray(agents)) { - return true; - } - - const agent = agents.find((candidate) => candidate.name === currentAgent); - if (agent?.mode === "subagent") { - return false; - } + const agent = response?.data?.find((candidate) => candidate.name === currentAgent); + return agent?.mode !== "subagent"; } catch { return true; } - - return true; } - async function handoffToImplementationAgent(sessionID) { + async function handoff(sessionID) { if (!implementationAgent) { return null; } @@ -162,7 +329,6 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => { parts: [{ type: "text", text: IMPLEMENTATION_PROMPT }], }, }); - return { agent: implementationAgent }; } catch (error) { return { @@ -179,66 +345,58 @@ export const OpenPlanAnnotatorPlugin = async (ctx) => { return; } - if (!currentSystem.includes("annotate_plan")) { - const shouldInject = await shouldInjectPlanReviewInstructions(input?.sessionID); - if (shouldInject) { - output.system.push(PLAN_REVIEW_INSTRUCTIONS); - } + if (!currentSystem.includes("annotate_plan") && (await shouldInjectInstructions(input?.sessionID))) { + output.system.push(PLAN_REVIEW_INSTRUCTIONS); } }, - tool: { annotate_plan: tool({ description: "Submit a markdown plan for interactive user review. Returns plain-text execution or revision instructions for the agent.", - args: { plan: tool.schema.string().describe("The complete implementation plan in markdown format"), summary: tool.schema.string().optional().describe("Optional one-line plan summary"), }, - async execute(args, context) { const result = await runPlanReview({ plan: args.plan, sessionId: context.sessionID, cwd: ctx.directory, }); - const feedback = result.approved ? "" : (result.feedback ?? "Plan changes requested."); - if (result.approved) { - const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."]; - - if (args.summary) { - lines.push(`Summary: ${args.summary}`); - } - - const handoffResult = await handoffToImplementationAgent(context.sessionID); - if (handoffResult) { - if (handoffResult.warning) { - lines.push(`Auto-switch warning: ${handoffResult.warning}`); - } else { - lines.push(`Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`); - } - } - - lines.push("Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan."); - lines.push("Execute the approved plan directly now — write code, create files, and make changes."); - lines.push("Do not call `annotate_plan` again unless the user explicitly requests re-planning."); + if (!result.approved) { + return [ + "Plan review status: plan_status=rejected.", + "State transition: next_state=PLAN_DRAFT.", + "", + "## User feedback", + "", + feedback, + "", + "Revise the plan using this feedback, then submit the revised draft once via `annotate_plan`.", + ].join("\n"); + } - return lines.join("\n\n"); + const lines = ["Plan review status: plan_status=approved.", "State transition: next_state=EXECUTION."]; + if (args.summary) { + lines.push(`Summary: ${args.summary}`); } - return [ - "Plan review status: plan_status=rejected.", - "State transition: next_state=PLAN_DRAFT.", - "", - "## User feedback", - "", - feedback, - "", - "Revise the plan using this feedback, then submit the revised draft once via `annotate_plan`.", - ].join("\n"); + const handoffResult = await handoff(context.sessionID); + if (handoffResult) { + lines.push( + handoffResult.warning + ? `Auto-switch warning: ${handoffResult.warning}` + : `Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`, + ); + } + lines.push( + "Replan intent: explicit_replan=false unless the user explicitly asks to revise the plan.", + "Execute the approved plan directly now — write code, create files, and make changes.", + "Do not call `annotate_plan` again unless the user explicitly requests re-planning.", + ); + return lines.join("\n\n"); }, }), }, diff --git a/opencode/index.test.ts b/opencode/index.test.ts index 22063b0..abe4da4 100644 --- a/opencode/index.test.ts +++ b/opencode/index.test.ts @@ -4,51 +4,153 @@ afterEach(() => { mock.restore(); }); +function createMockEditor() { + const tools = new Map(); + return { + tools, + add(tool) { + tools.set(tool.name, tool); + }, + update() {}, + remove() {}, + }; +} + function createPluginContext() { + const editor = createMockEditor(); + const hooks = new Map(); return { - directory: process.cwd(), - client: { + ctx: { + location: { directory: process.cwd() }, + options: {}, + tool: { + transform: async (callback) => { + callback(editor); + return { dispose: async () => {} }; + }, + list: async () => [], + reload: async () => {}, + }, session: { - messages: async () => ({ data: [] }), - prompt: async () => ({ data: null }), + switchAgent: async () => {}, + prompt: async () => ({ id: "inbox-1" }), + hook: async (name, callback) => { + hooks.set(name, callback); + return { dispose: async () => {} }; + }, }, - app: { - agents: async () => ({ data: [] }), + agent: { + list: async () => ({ data: [] }), + get: async () => ({ data: undefined }), }, }, + editor, + hooks, }; } -describe("annotate_plan tool output", () => { - test("returns plain text execution instructions after approval", async () => { +async function loadPluginWithBridge(runPlanReviewImpl, suffix) { + mock.module("./bridge.js", () => ({ + runPlanReview: runPlanReviewImpl, + })); + + const mod = await import(`./index.js?${suffix}-${Date.now()}`); + return mod; +} + +describe("annotate_plan tool output (V2)", () => { + test("V2 entrypoints default to a plugin definition object", async () => { mock.module("./bridge.js", () => ({ runPlanReview: async () => ({ approved: true }), })); + const suffix = `shape-${Date.now()}`; + const modules = await Promise.all([import(`../index.js?${suffix}`), import(`../server.js?${suffix}`)]); + for (const mod of modules) { + expect(typeof mod.default).toBe("object"); + expect(mod.default.id).toBe("open-plan-annotator"); + expect(typeof mod.default.setup).toBe("function"); + } + }); - const { OpenPlanAnnotatorPlugin } = await import(`./index.js?approved-${Date.now()}`); - const plugin = await OpenPlanAnnotatorPlugin(createPluginContext()); + test("legacy entrypoint retains the V1 plugin function", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "v1-shape"); + expect(typeof mod.default).toBe("function"); + expect(mod.OpenPlanAnnotatorPlugin).toBe(mod.default); + const plugin = await mod.default({ + directory: process.cwd(), + client: { + session: { + messages: async () => ({ data: [] }), + prompt: async () => ({ data: null }), + }, + app: { + agents: async () => ({ data: [] }), + }, + }, + }); const result = await plugin.tool.annotate_plan.execute({ plan: "# Plan" }, { sessionID: "session-1" }); - - expect(typeof result).toBe("string"); expect(result).toContain("plan_status=approved"); - expect(result).toContain("next_state=EXECUTION"); - expect(result).toContain("Do not call `annotate_plan` again"); + }); + + test("returns plain text execution instructions after approval", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "approved"); + const { ctx, editor } = createPluginContext(); + const cleanup = await mod.OpenPlanAnnotatorPluginV2.setup(ctx); + expect(typeof cleanup === "function" || cleanup === undefined).toBe(true); + + const tool = editor.tools.get("annotate_plan"); + expect(tool).toBeDefined(); + const result = await tool.execute({ plan: "# Plan" }, { sessionID: "session-1" }); + + const content = typeof result === "string" ? result : (result.content ?? ""); + expect(content).toContain("plan_status=approved"); + expect(content).toContain("next_state=EXECUTION"); + expect(content).toContain("Do not call `annotate_plan` again"); }); test("returns plain text revision instructions after rejection", async () => { - mock.module("./bridge.js", () => ({ - runPlanReview: async () => ({ approved: false, feedback: "Need rollback steps." }), - })); + const mod = await loadPluginWithBridge( + async () => ({ approved: false, feedback: "Need rollback steps." }), + "rejected", + ); + const { ctx, editor } = createPluginContext(); + await mod.OpenPlanAnnotatorPluginV2.setup(ctx); - const { OpenPlanAnnotatorPlugin } = await import(`./index.js?rejected-${Date.now()}`); - const plugin = await OpenPlanAnnotatorPlugin(createPluginContext()); + const tool = editor.tools.get("annotate_plan"); + const result = await tool.execute({ plan: "# Plan" }, { sessionID: "session-2" }); + + const content = typeof result === "string" ? result : (result.content ?? ""); + expect(content).toContain("plan_status=rejected"); + expect(content).toContain("next_state=PLAN_DRAFT"); + expect(content).toContain("Need rollback steps."); + }); + + test("injects plan review instructions via session context hook", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "hook"); + const { ctx, hooks } = createPluginContext(); + await mod.OpenPlanAnnotatorPluginV2.setup(ctx); + + const hook = hooks.get("context"); + expect(hook).toBeDefined(); + const event = { agent: "plan", sessionID: "session-1", system: [] }; + await hook(event); + expect(event.system.length).toBe(1); + expect(event.system[0].text).toContain("annotate_plan"); + + // does not duplicate when already present + await hook(event); + expect(event.system.length).toBe(1); + }); - const result = await plugin.tool.annotate_plan.execute({ plan: "# Plan" }, { sessionID: "session-2" }); + test("skips injection for the implementation agent", async () => { + const mod = await loadPluginWithBridge(async () => ({ approved: true }), "skip-handoff"); + const { ctx, hooks } = createPluginContext(); + await mod.OpenPlanAnnotatorPluginV2.setup(ctx); - expect(typeof result).toBe("string"); - expect(result).toContain("plan_status=rejected"); - expect(result).toContain("next_state=PLAN_DRAFT"); - expect(result).toContain("Need rollback steps."); + const hook = hooks.get("context"); + const event = { agent: "build", sessionID: "s", system: [] }; + await hook(event); + expect(event.system.length).toBe(0); }); }); diff --git a/package.json b/package.json index c4e13d2..7f455c2 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "shared/versionInfo.mjs", "shared/planReview.mjs", "shared/piExtension.mjs", + "index.js", + "server.js", ".claude-plugin/", "opencode/bridge.js", "opencode/config.js", diff --git a/scripts/check-package-files.mjs b/scripts/check-package-files.mjs index 8291fe9..3efbb5c 100644 --- a/scripts/check-package-files.mjs +++ b/scripts/check-package-files.mjs @@ -18,6 +18,8 @@ const packages = [ "shared/cliMode.mjs", "shared/planReview.mjs", "shared/piExtension.mjs", + "index.js", + "server.js", ], }, { diff --git a/server.js b/server.js new file mode 100644 index 0000000..e59e2f2 --- /dev/null +++ b/server.js @@ -0,0 +1 @@ +export { OpenPlanAnnotatorPluginV2 as default } from "./opencode/index.js";