From 3078ee368f9c34244996b35b1e93684ec776e353 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 15 Apr 2026 18:19:54 -0700 Subject: [PATCH 1/6] feat(opencode): add workflow-scoped submit_plan access --- apps/opencode-plugin/index.ts | 107 +++++++------ apps/opencode-plugin/workflow.test.ts | 210 ++++++++++++++++++++++++++ apps/opencode-plugin/workflow.ts | 183 ++++++++++++++++++++++ 3 files changed, 454 insertions(+), 46 deletions(-) create mode 100644 apps/opencode-plugin/workflow.test.ts create mode 100644 apps/opencode-plugin/workflow.ts diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index dad10732..fef1c199 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -58,9 +58,19 @@ import { } from "./commands"; import { planDenyFeedback } from "@plannotator/shared/feedback-templates"; import { - normalizeEditPermission, stripConflictingPlanModeRules, } from "./plan-mode"; +import { + applyWorkflowConfig, + isPlanningAgent, + normalizeWorkflowOptions, + shouldApplyToolDefinitionRewrites, + shouldInjectFullPlanningPrompt, + shouldInjectGenericPlanReminder, + shouldRegisterSubmitPlan, + shouldRejectSubmitPlanForAgent, + type PlannotatorOpenCodeOptions, +} from "./workflow"; // Lazy-load HTML at first use instead of embedding in the bundle. // The two SPA files are ~20 MB combined — inlining them as string literals @@ -170,7 +180,20 @@ Only write and submit a plan once you have sufficient context. // ── Plugin ──────────────────────────────────────────────────────────────── -export const PlannotatorPlugin: Plugin = async (ctx) => { +function getLastUserAgentFromMessages(messages: any[] | undefined): string | undefined { + if (!messages) return undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.info?.role === "user" && typeof msg.info.agent === "string") { + return msg.info.agent; + } + } + return undefined; +} + +export const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpenCodeOptions) => { + const workflowOptions = normalizeWorkflowOptions(rawOptions); + // Preload HTML in background — populates the sync cache before first use Bun.file(resolveBundledHtmlPath("plannotator.html")).text().then(h => { _planHtml = h; }); Bun.file(resolveBundledHtmlPath("review-editor.html")).text().then(h => { _reviewHtml = h; }); @@ -216,36 +239,25 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { return val === "1" || val === "true"; } - return { - // Register submit_plan as primary-only tool (hidden from sub-agents by default) + const plugin: any = { config: async (opencodeConfig) => { - if (!allowSubagents()) { - const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []; - if (!existingPrimaryTools.includes("submit_plan")) { - opencodeConfig.experimental = { - ...opencodeConfig.experimental, - primary_tools: [...existingPrimaryTools, "submit_plan"], - }; - } - } - - // Allow the plan agent to write .md files anywhere. - // OpenCode's built-in plan agent uses relative-path globs that break - // when worktree != cwd (non-git projects). Per-agent config merges - // last, so this only affects the plan agent. - opencodeConfig.agent ??= {}; - opencodeConfig.agent.plan ??= {}; - opencodeConfig.agent.plan.permission ??= {}; - opencodeConfig.agent.plan.permission.edit = { - ...normalizeEditPermission(opencodeConfig.agent.plan.permission.edit), - "*.md": "allow", - }; + applyWorkflowConfig(opencodeConfig, workflowOptions, allowSubagents()); }, // Replace OpenCode's "STRICTLY FORBIDDEN" plan mode prompt with a version // that allows markdown file writing. OpenCode's original blocks ALL file edits, // but we need the agent to write plans, specs, docs, etc. "experimental.chat.messages.transform": async (input, output) => { + if (workflowOptions.workflow === "manual") return; + + const lastUserAgent = getLastUserAgentFromMessages(output.messages); + if ( + workflowOptions.workflow === "plan-agent" + && !isPlanningAgent(lastUserAgent, workflowOptions) + ) { + return; + } + for (const message of output.messages) { if (message.info.role !== "user") continue; for (const part of message.parts as any[]) { @@ -276,6 +288,8 @@ tools (except writing markdown files), or otherwise make changes to the system. // Suppress plan_exit — redirect to submit_plan // Override todowrite — defer to submit_plan during planning "tool.definition": async (input, output) => { + if (!shouldApplyToolDefinitionRewrites(workflowOptions)) return; + if (input.toolID === "plan_exit") { output.description = "Do not call this tool. Use submit_plan instead — it opens a visual review UI for plan approval."; @@ -288,12 +302,15 @@ tools (except writing markdown files), or otherwise make changes to the system. // Inject planning instructions into system prompt "experimental.chat.system.transform": async (input, output) => { + if (workflowOptions.workflow === "manual") return; + const systemText = output.system.join("\n"); if (systemText.toLowerCase().includes("title generator") || systemText.toLowerCase().includes("generate a title")) { return; } let lastUserAgent: string | undefined; + let isSubagent = false; try { const messagesResponse = await ctx.client.session.messages({ // @ts-ignore - sessionID exists on input @@ -301,22 +318,10 @@ tools (except writing markdown files), or otherwise make changes to the system. }); const messages = messagesResponse.data; - if (messages) { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.info.role === "user") { - // @ts-ignore - UserMessage has agent field - lastUserAgent = msg.info.agent; - break; - } - } - } + lastUserAgent = getLastUserAgentFromMessages(messages); if (!lastUserAgent) return; - // Build agent doesn't need planning instructions - if (lastUserAgent === "build") return; - // Cache agents list (static per session) if (!cachedAgents) { const agentsResponse = await ctx.client.app.agents({ @@ -326,22 +331,21 @@ tools (except writing markdown files), or otherwise make changes to the system. } const agent = cachedAgents.find((a: { name: string }) => a.name === lastUserAgent); - // Skip sub-agents // @ts-ignore - Agent has mode field - if (agent?.mode === "subagent") return; + isSubagent = agent?.mode === "subagent"; } catch { return; } - // Plan agent: strip conflicting OpenCode rules, inject full prompt - if (lastUserAgent === "plan") { + if (shouldInjectFullPlanningPrompt(lastUserAgent, workflowOptions)) { output.system = stripConflictingPlanModeRules(output.system); output.system.push(getPlanningPrompt()); return; } - // Other primary agents: minimal reminder about the tool + if (!shouldInjectGenericPlanReminder(lastUserAgent, isSubagent, workflowOptions)) return; + output.system.push(`## Plan Submission When you have completed your plan, call the \`submit_plan\` tool to submit it for user review. Pass your plan as markdown text, or pass an absolute file path to a .md file. @@ -415,8 +419,10 @@ Do NOT proceed with implementation until your plan is approved.`); if (commandName === "plannotator-archive") return handleArchiveCommand(event, deps); }, + }; - tool: { + if (shouldRegisterSubmitPlan(workflowOptions)) { + plugin.tool = { submit_plan: tool({ description: "Planning tool used to submit a plan to the user for review. Before calling this tool you must conduct interactive and exploratory analysis in order to submit a quality plan. Ask questions. Explore the codebase for context if needed. Only call submit_plan once you have enough details to create a quality plan. Work with the user to get those details. Pass either markdown text or an absolute path to a .md file.", @@ -427,6 +433,13 @@ Do NOT proceed with implementation until your plan is approved.`); }, async execute(args, context) { + const invokingAgent = (context as { agent?: string }).agent; + if (shouldRejectSubmitPlanForAgent(invokingAgent, workflowOptions)) { + return `Plannotator is configured for plan-agent mode. submit_plan can only be called by: ${workflowOptions.planningAgents.join(", ")}. + +Use /plannotator-last or /plannotator-annotate for manual review, or set workflow to all-agents to allow broader submit_plan access.`; + } + // Auto-detect: file path or plan text let planContent: string; let sourceFilePath: string | undefined; @@ -518,8 +531,10 @@ Proceed with implementation, incorporating these notes where applicable.`; } }, }), - }, - }; + }; + } + + return plugin; }; export default PlannotatorPlugin; diff --git a/apps/opencode-plugin/workflow.test.ts b/apps/opencode-plugin/workflow.test.ts new file mode 100644 index 00000000..ef5a7493 --- /dev/null +++ b/apps/opencode-plugin/workflow.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; +import { + applyWorkflowConfig, + normalizeWorkflowOptions, + shouldApplyToolDefinitionRewrites, + shouldInjectFullPlanningPrompt, + shouldInjectGenericPlanReminder, + shouldRegisterSubmitPlan, + shouldRejectSubmitPlanForAgent, +} from "./workflow"; + +describe("normalizeWorkflowOptions", () => { + test("defaults omitted options to plan-agent with the plan agent", () => { + const options = normalizeWorkflowOptions(undefined); + + expect(options.workflow).toBe("plan-agent"); + expect(options.planningAgents).toEqual(["plan"]); + expect(options.planningAgentSet.has("plan")).toBe(true); + }); + + test("falls back to plan-agent for unknown workflows", () => { + const options = normalizeWorkflowOptions({ workflow: "auto-everywhere" }); + + expect(options.workflow).toBe("plan-agent"); + }); + + test("trims and deduplicates planning agents", () => { + const options = normalizeWorkflowOptions({ + workflow: "plan-agent", + planningAgents: [" plan ", "", "planner", "plan", 123], + }); + + expect(options.planningAgents).toEqual(["plan", "planner"]); + }); + + test("uses the default planning agent when the configured list is empty", () => { + const options = normalizeWorkflowOptions({ + workflow: "plan-agent", + planningAgents: ["", " "], + }); + + expect(options.planningAgents).toEqual(["plan"]); + }); +}); + +describe("workflow gates", () => { + test("manual mode is commands-only", () => { + const options = normalizeWorkflowOptions({ workflow: "manual" }); + + expect(shouldRegisterSubmitPlan(options)).toBe(false); + expect(shouldApplyToolDefinitionRewrites(options)).toBe(false); + expect(shouldInjectFullPlanningPrompt("plan", options)).toBe(false); + expect(shouldRejectSubmitPlanForAgent("build", options)).toBe(false); + }); + + test("plan-agent mode injects only for configured planning agents", () => { + const options = normalizeWorkflowOptions({ + workflow: "plan-agent", + planningAgents: ["plan", "planner"], + }); + + expect(shouldRegisterSubmitPlan(options)).toBe(true); + expect(shouldApplyToolDefinitionRewrites(options)).toBe(true); + expect(shouldInjectFullPlanningPrompt("plan", options)).toBe(true); + expect(shouldInjectFullPlanningPrompt("planner", options)).toBe(true); + expect(shouldInjectFullPlanningPrompt("build", options)).toBe(false); + expect(shouldInjectGenericPlanReminder("build", false, options)).toBe(false); + }); + + test("all-agents mode keeps the generic primary-agent reminder", () => { + const options = normalizeWorkflowOptions({ workflow: "all-agents" }); + + expect(shouldInjectGenericPlanReminder("reviewer", false, options)).toBe(true); + expect(shouldInjectGenericPlanReminder("build", false, options)).toBe(false); + expect(shouldInjectGenericPlanReminder("reviewer", true, options)).toBe(false); + expect(shouldInjectGenericPlanReminder("plan", false, options)).toBe(false); + }); + + test("runtime guard rejects only non-planning agents in plan-agent mode", () => { + const planAgent = normalizeWorkflowOptions({ workflow: "plan-agent" }); + const allAgents = normalizeWorkflowOptions({ workflow: "all-agents" }); + + expect(shouldRejectSubmitPlanForAgent("plan", planAgent)).toBe(false); + expect(shouldRejectSubmitPlanForAgent("build", planAgent)).toBe(true); + expect(shouldRejectSubmitPlanForAgent(undefined, planAgent)).toBe(true); + expect(shouldRejectSubmitPlanForAgent("build", allAgents)).toBe(false); + }); +}); + +describe("applyWorkflowConfig", () => { + test("manual mode leaves OpenCode config untouched", () => { + const config: any = {}; + + applyWorkflowConfig(config, normalizeWorkflowOptions({ workflow: "manual" }), false); + + expect(config).toEqual({}); + }); + + test("plan-agent mode exposes submit_plan to plan and denies build", () => { + const config: any = { + experimental: { + primary_tools: ["bash"], + other: true, + }, + }; + + applyWorkflowConfig(config, normalizeWorkflowOptions(undefined), false); + + expect(config.experimental).toEqual({ + primary_tools: ["bash", "submit_plan"], + other: true, + }); + expect(config.agent.plan.permission.submit_plan).toBe("allow"); + expect(config.agent.plan.permission.edit).toEqual({ "*.md": "allow" }); + expect(config.agent.build.permission.submit_plan).toBe("deny"); + }); + + test("plan-agent mode preserves user agent fields and existing permissions", () => { + const config: any = { + agent: { + planner: { + mode: "primary", + model: "test-model", + prompt: "custom prompt", + permission: { + bash: "deny", + edit: "deny", + }, + }, + }, + }; + + applyWorkflowConfig( + config, + normalizeWorkflowOptions({ + workflow: "plan-agent", + planningAgents: ["planner"], + }), + false, + ); + + expect(config.agent.planner.model).toBe("test-model"); + expect(config.agent.planner.prompt).toBe("custom prompt"); + expect(config.agent.planner.permission.bash).toBe("deny"); + expect(config.agent.planner.permission.submit_plan).toBe("allow"); + expect(config.agent.planner.permission.edit).toEqual({ + "*": "deny", + "*.md": "allow", + }); + }); + + test("plan-agent mode denies user-configured non-planning primary agents", () => { + const config: any = { + agent: { + reviewer: { + mode: "primary", + permission: { + bash: "ask", + }, + }, + helper: { + mode: "subagent", + permission: { + bash: "ask", + }, + }, + }, + }; + + applyWorkflowConfig(config, normalizeWorkflowOptions(undefined), false); + + expect(config.agent.reviewer.permission.submit_plan).toBe("deny"); + expect(config.agent.helper.permission.submit_plan).toBeUndefined(); + }); + + test("allow-subagents mode also denies non-planning subagents", () => { + const config: any = { + agent: { + helper: { + mode: "subagent", + permission: {}, + }, + }, + }; + + applyWorkflowConfig(config, normalizeWorkflowOptions(undefined), true); + + expect(config.experimental?.primary_tools).toBeUndefined(); + expect(config.agent.helper.permission.submit_plan).toBe("deny"); + }); + + test("all-agents mode preserves broad access while allowing planning agents", () => { + const config: any = { + agent: { + build: { + permission: { + bash: "ask", + }, + }, + }, + }; + + applyWorkflowConfig(config, normalizeWorkflowOptions({ workflow: "all-agents" }), false); + + expect(config.agent.plan.permission.submit_plan).toBe("allow"); + expect(config.agent.build.permission.submit_plan).toBeUndefined(); + expect(config.agent.build.permission.bash).toBe("ask"); + expect(config.experimental.primary_tools).toEqual(["submit_plan"]); + }); +}); diff --git a/apps/opencode-plugin/workflow.ts b/apps/opencode-plugin/workflow.ts new file mode 100644 index 00000000..1dd43269 --- /dev/null +++ b/apps/opencode-plugin/workflow.ts @@ -0,0 +1,183 @@ +import { normalizeEditPermission } from "./plan-mode"; + +export type WorkflowMode = "manual" | "plan-agent" | "all-agents"; + +export interface PlannotatorOpenCodeOptions { + workflow?: unknown; + planningAgents?: unknown; +} + +export interface NormalizedWorkflowOptions { + workflow: WorkflowMode; + planningAgents: string[]; + planningAgentSet: Set; +} + +const WORKFLOWS = new Set(["manual", "plan-agent", "all-agents"]); +const DEFAULT_WORKFLOW: WorkflowMode = "plan-agent"; +const DEFAULT_PLANNING_AGENTS = ["plan"]; + +type AgentConfig = { + mode?: string; + permission?: Record; + [key: string]: any; +}; + +type OpenCodeConfig = { + experimental?: { + primary_tools?: string[]; + [key: string]: any; + }; + agent?: Record; + [key: string]: any; +}; + +export function normalizeWorkflowOptions( + rawOptions: PlannotatorOpenCodeOptions | null | undefined, +): NormalizedWorkflowOptions { + const rawWorkflow = typeof rawOptions?.workflow === "string" + ? rawOptions.workflow.trim() + : ""; + const workflow = WORKFLOWS.has(rawWorkflow as WorkflowMode) + ? rawWorkflow as WorkflowMode + : DEFAULT_WORKFLOW; + + const planningAgents = normalizePlanningAgents(rawOptions?.planningAgents); + return { + workflow, + planningAgents, + planningAgentSet: new Set(planningAgents), + }; +} + +function normalizePlanningAgents(value: unknown): string[] { + if (!Array.isArray(value)) return DEFAULT_PLANNING_AGENTS; + + const seen = new Set(); + const agents: string[] = []; + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + agents.push(trimmed); + } + + return agents.length > 0 ? agents : DEFAULT_PLANNING_AGENTS; +} + +export function isPlanningAgent( + agentName: string | undefined, + options: NormalizedWorkflowOptions, +): boolean { + return !!agentName && options.planningAgentSet.has(agentName); +} + +export function shouldRegisterSubmitPlan(options: NormalizedWorkflowOptions): boolean { + return options.workflow !== "manual"; +} + +export function shouldApplyToolDefinitionRewrites(options: NormalizedWorkflowOptions): boolean { + return options.workflow !== "manual"; +} + +export function shouldInjectFullPlanningPrompt( + agentName: string | undefined, + options: NormalizedWorkflowOptions, +): boolean { + return options.workflow !== "manual" && isPlanningAgent(agentName, options); +} + +export function shouldInjectGenericPlanReminder( + agentName: string | undefined, + isSubagent: boolean, + options: NormalizedWorkflowOptions, +): boolean { + if (options.workflow !== "all-agents") return false; + if (!agentName || isSubagent) return false; + if (agentName === "build") return false; + return !isPlanningAgent(agentName, options); +} + +export function shouldRejectSubmitPlanForAgent( + agentName: string | undefined, + options: NormalizedWorkflowOptions, +): boolean { + return options.workflow === "plan-agent" && !isPlanningAgent(agentName, options); +} + +export function applyWorkflowConfig( + opencodeConfig: OpenCodeConfig, + options: NormalizedWorkflowOptions, + allowSubagents: boolean, +): void { + if (options.workflow === "manual") return; + + if (!allowSubagents) { + const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []; + if (!existingPrimaryTools.includes("submit_plan")) { + opencodeConfig.experimental = { + ...opencodeConfig.experimental, + primary_tools: [...existingPrimaryTools, "submit_plan"], + }; + } + } + + opencodeConfig.agent ??= {}; + + for (const agentName of options.planningAgents) { + allowPlanningAgent(opencodeConfig, agentName); + } + + if (options.workflow === "all-agents") return; + + if (!options.planningAgentSet.has("build")) { + denySubmitPlan(opencodeConfig, "build"); + } + + for (const [agentName, agentConfig] of Object.entries(opencodeConfig.agent)) { + if (options.planningAgentSet.has(agentName)) { + allowPlanningAgent(opencodeConfig, agentName); + continue; + } + + if (isPrimaryCapableAgent(agentConfig, allowSubagents)) { + denySubmitPlan(opencodeConfig, agentName); + } + } +} + +function allowPlanningAgent(opencodeConfig: OpenCodeConfig, agentName: string): void { + const agent = ensureAgentConfig(opencodeConfig, agentName); + const permission = ensurePermission(agent); + permission.submit_plan = "allow"; + permission.edit = { + ...normalizeEditPermission(permission.edit), + "*.md": "allow", + }; +} + +function denySubmitPlan(opencodeConfig: OpenCodeConfig, agentName: string): void { + const agent = ensureAgentConfig(opencodeConfig, agentName); + ensurePermission(agent).submit_plan = "deny"; +} + +function ensureAgentConfig(opencodeConfig: OpenCodeConfig, agentName: string): AgentConfig { + opencodeConfig.agent ??= {}; + opencodeConfig.agent[agentName] ??= {}; + return opencodeConfig.agent[agentName]; +} + +function ensurePermission(agent: AgentConfig): Record { + if (!agent.permission || typeof agent.permission !== "object" || Array.isArray(agent.permission)) { + agent.permission = {}; + } + return agent.permission; +} + +function isPrimaryCapableAgent(agent: AgentConfig, allowSubagents: boolean): boolean { + const mode = typeof agent.mode === "string" ? agent.mode : "all"; + if (mode === "subagent") return allowSubagents; + return mode === "primary" || mode === "all" || !agent.mode; +} + From 0813b2b9f9152c8182bac2ec73b5dad6ef5d2bf4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 15 Apr 2026 18:20:04 -0700 Subject: [PATCH 2/6] fix(opencode): support folder annotation command --- apps/opencode-plugin/commands.ts | 38 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 4f1350c2..1f2e474d 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -21,7 +21,8 @@ import { import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git"; import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; -import { resolveMarkdownFile } from "@plannotator/shared/resolve-file"; +import { resolveMarkdownFile, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { statSync } from "fs"; @@ -147,18 +148,24 @@ export async function handleAnnotateCommand( event: any, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps; // @ts-ignore - Event properties contain arguments - const filePath = event.properties?.arguments || event.arguments || ""; + let filePath = event.properties?.arguments || event.arguments || ""; if (!filePath) { - client.app.log({ level: "error", message: "Usage: /plannotator-annotate " }); + client.app.log({ level: "error", message: "Usage: /plannotator-annotate " }); return; } + if (filePath.startsWith("@")) { + filePath = filePath.slice(1); + } + let markdown: string; let absolutePath: string; + let folderPath: string | undefined; + let annotateMode: "annotate" | "annotate-folder" = "annotate"; let sourceInfo: string | undefined; // --- URL annotation --- @@ -177,10 +184,27 @@ export async function handleAnnotateCommand( absolutePath = filePath; sourceInfo = filePath; } else { - const projectRoot = process.cwd(); + const projectRoot = directory || process.cwd(); const resolvedArg = path.resolve(projectRoot, filePath); - if (/\.html?$/i.test(resolvedArg)) { + let isFolder = false; + try { + isFolder = statSync(resolvedArg).isDirectory(); + } catch { + // Not a directory, fall through to file resolution. + } + + if (isFolder) { + if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + client.app.log({ level: "error", message: `No markdown or HTML files found in ${resolvedArg}` }); + return; + } + folderPath = resolvedArg; + absolutePath = resolvedArg; + markdown = ""; + annotateMode = "annotate-folder"; + client.app.log({ level: "info", message: `Opening annotation UI for folder ${resolvedArg}...` }); + } else if (/\.html?$/i.test(resolvedArg)) { // HTML file annotation — convert to markdown via Turndown let fileSize: number; try { @@ -225,6 +249,8 @@ export async function handleAnnotateCommand( markdown, filePath: absolutePath, origin: "opencode", + mode: annotateMode, + folderPath, sourceInfo, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), From c1fa9e10ba5fc4cc1005b5279cc263189ba4843f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 15 Apr 2026 18:20:15 -0700 Subject: [PATCH 3/6] docs(opencode): document workflow mode migration --- .../docs/getting-started/configuration.md | 18 +- .../docs/getting-started/installation.md | 2 +- .../src/content/docs/guides/opencode.md | 58 ++- .../content/docs/guides/troubleshooting.md | 19 + apps/opencode-plugin/README.md | 53 ++- docs/specs/opencode-workflow-modes.md | 395 ++++++++++++++++++ 6 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 docs/specs/opencode-workflow-modes.md diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md index 43dca687..d815a98e 100644 --- a/apps/marketing/src/content/docs/getting-started/configuration.md +++ b/apps/marketing/src/content/docs/getting-started/configuration.md @@ -57,7 +57,23 @@ OpenCode uses `opencode.json` to load the plugin: } ``` -This registers the `submit_plan` tool. Slash commands (`/plannotator-review`, `/plannotator-annotate`) require the CLI to be installed separately via the install script. +This uses the default `plan-agent` workflow: `submit_plan` is registered for OpenCode's `plan` agent, while `build` and other primary agents do not see it. + +To configure the workflow explicitly: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +Use `workflow: "manual"` for commands-only mode, or `workflow: "all-agents"` to restore the legacy behavior where primary agents can call `submit_plan`. Slash commands (`/plannotator-review`, `/plannotator-annotate`, `/plannotator-last`) require the CLI to be installed separately via the install script. ## Plan saving diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index 61b78e3a..eeff0e0c 100644 --- a/apps/marketing/src/content/docs/getting-started/installation.md +++ b/apps/marketing/src/content/docs/getting-started/installation.md @@ -106,7 +106,7 @@ Add the plugin to your `opencode.json`: } ``` -Restart OpenCode. The `submit_plan` tool is now available. +Restart OpenCode. By default, `submit_plan` is available to OpenCode's `plan` agent only. Use the [OpenCode guide](/docs/guides/opencode/) if you want commands-only mode or the legacy all-agents behavior. For slash commands (`/plannotator-review`, `/plannotator-annotate`), also run the install script: diff --git a/apps/marketing/src/content/docs/guides/opencode.md b/apps/marketing/src/content/docs/guides/opencode.md index 94f4fb21..ca3fc7c3 100644 --- a/apps/marketing/src/content/docs/guides/opencode.md +++ b/apps/marketing/src/content/docs/guides/opencode.md @@ -6,18 +6,66 @@ sidebar: section: "Getting Started" --- -Plannotator integrates with OpenCode as an npm plugin that registers a `submit_plan` tool. When the agent calls `submit_plan`, Plannotator opens the review UI in your browser. +Plannotator integrates with OpenCode as an npm plugin. By default it makes `submit_plan` available to OpenCode's `plan` agent only, so OpenCode plan mode can use Plannotator without exposing the tool to `build`. ## How the plugin works The OpenCode plugin (`@plannotator/opencode`) hooks into OpenCode's plugin system: -1. The plugin registers a `submit_plan` tool that the agent can call +1. The plugin registers a `submit_plan` tool for configured planning agents 2. When `submit_plan` is called with a plan, Plannotator starts a local server and opens the browser 3. The user reviews and annotates the plan 4. On approval, the plugin returns a success response to the agent 5. On denial, the plugin returns the feedback for the agent to revise +## Workflow modes + +OpenCode support has three explicit modes: + +- **`plan-agent`** (default): `submit_plan` is available to configured planning agents only. The default planning agent is `plan`. +- **`manual`**: `submit_plan` is not registered. Use `/plannotator-last`, `/plannotator-annotate`, `/plannotator-review`, and `/plannotator-archive` when you want Plannotator. +- **`all-agents`**: legacy broad behavior. Primary agents can see and call `submit_plan`. + +Default config: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +If you want the old broad behavior: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "all-agents" + }] + ] +} +``` + +If you want commands only: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "manual" + }] + ] +} +``` + ## Approve with annotations Unlike Claude Code, OpenCode supports feedback on approval. This means: @@ -40,7 +88,7 @@ If the configured agent isn't found in the current OpenCode session, Plannotator ## Slash commands -The plugin registers three slash commands: +The plugin registers slash commands that work in every workflow mode: ### `/plannotator-review` @@ -54,7 +102,7 @@ Requires the CLI to be installed (the slash command runs `plannotator review` un ### `/plannotator-annotate ` -Opens a markdown file in the annotation UI. Also requires the CLI. +Opens a markdown file, directory, or URL in the annotation UI. Also requires the CLI. ### `/plannotator-last` @@ -79,7 +127,7 @@ Add to your `opencode.json`: } ``` -Restart OpenCode. The `submit_plan` tool is now available. See the [installation guide](/docs/getting-started/installation/) for full details. +Restart OpenCode. With the default workflow, `submit_plan` is available to the `plan` agent. If you need `build` or another primary agent to call it, set `workflow` to `all-agents`. See the [installation guide](/docs/getting-started/installation/) for full details. ## Devcontainer / Docker diff --git a/apps/marketing/src/content/docs/guides/troubleshooting.md b/apps/marketing/src/content/docs/guides/troubleshooting.md index 54f11441..1c54494c 100644 --- a/apps/marketing/src/content/docs/guides/troubleshooting.md +++ b/apps/marketing/src/content/docs/guides/troubleshooting.md @@ -63,3 +63,22 @@ If `ExitPlanMode` doesn't trigger Plannotator: 2. Restart Claude Code after installing (hooks load on startup) 3. Verify `plannotator` is on your PATH: `which plannotator` 4. Check that plan mode is enabled in your Claude Code session + +## OpenCode build agent cannot call `submit_plan` + +This is expected with the default OpenCode workflow. Plannotator now defaults to `plan-agent`, which keeps `submit_plan` available to OpenCode's `plan` agent and hides or denies it for `build` and other non-planning primary agents. + +If you want the old broad behavior, opt in from `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "all-agents" + }] + ] +} +``` + +If you do not want automatic plan review at all, use `workflow: "manual"` and run `/plannotator-last` or `/plannotator-annotate` when you want Plannotator. diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index 6053ef1a..4e0e4084 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -28,7 +28,7 @@ Add to your `opencode.json`: } ``` -Restart OpenCode. The `submit_plan` tool is now available. +Restart OpenCode. By default, the `submit_plan` tool is available to OpenCode's `plan` agent, not to `build` or other primary agents. > **Slash commands:** Run the install script to get `/plannotator-review`, `/plannotator-annotate`, and `/plannotator-last`: > ```bash @@ -36,9 +36,57 @@ Restart OpenCode. The `submit_plan` tool is now available. > ``` > This also clears any cached plugin versions. +## Workflow Modes + +Plannotator supports three OpenCode workflows: + +- **`plan-agent`** (default): `submit_plan` is available to configured planning agents only. This keeps Plannotator integrated with OpenCode plan mode without nudging `build` to call it. +- **`manual`**: `submit_plan` is not registered. Use `/plannotator-last`, `/plannotator-annotate`, `/plannotator-review`, and `/plannotator-archive` when you want Plannotator. +- **`all-agents`**: legacy broad behavior. Primary agents can see and call `submit_plan`. + +Default config: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +Restore the old broad behavior: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "all-agents" + }] + ] +} +``` + +Use commands only: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "manual" + }] + ] +} +``` + ## How It Works -1. Agent calls `submit_plan` → Plannotator opens in your browser +1. The configured planning agent calls `submit_plan` → Plannotator opens in your browser 2. Select text → annotate (delete, replace, comment) 3. **Approve** → Agent proceeds with implementation 4. **Request changes** → Annotations sent back as structured feedback @@ -50,6 +98,7 @@ Restart OpenCode. The `submit_plan` tool is now available. - **Private sharing**: Plans and annotations compress into the URL itself—share a link, no accounts or backend required - **Plan Diff**: See what changed when the agent revises a plan after feedback - **Annotate last message**: Run `/plannotator-last` to annotate the agent's most recent response +- **Annotate files, folders, and URLs**: Run `/plannotator-annotate` when you want manual review of an artifact - **Obsidian integration**: Auto-save approved plans to your vault with frontmatter and tags ## Environment Variables diff --git a/docs/specs/opencode-workflow-modes.md b/docs/specs/opencode-workflow-modes.md new file mode 100644 index 00000000..657729e9 --- /dev/null +++ b/docs/specs/opencode-workflow-modes.md @@ -0,0 +1,395 @@ +# OpenCode Workflow Modes + +## Status + +Draft proposal for issue [#479](https://github.com/backnotprop/plannotator/issues/479). + +## Context + +Plannotator's OpenCode plugin currently exposes `submit_plan` broadly to primary +agents and nudges agents toward using it for plan review. This works well for +users who want Plannotator to own the plan approval loop, but recent feedback +shows three distinct workflows: + +1. Users who want Plannotator to integrate with OpenCode plan mode. +2. Users who want manual review through `/plannotator-last` and + `/plannotator-annotate`. +3. Users who want the legacy behavior where any primary agent can submit plans. + +The current implementation blurs those workflows. In particular, non-plan +primary agents can still see `submit_plan`, and some users experience the tool +as eager or intrusive during small plans, OpenSpec-style artifact planning, or +normal OpenCode flow. + +This spec makes those workflows explicit. + +## Goals + +- Keep OpenCode plan-mode integration as a first-class feature. +- Make `/plannotator-last` and `/plannotator-annotate` first-class manual + features, not fallback paths. +- Stop broad primary-agent exposure by default. +- Preserve the current broad behavior as an explicit compatibility mode. +- Support users who want Plannotator to gate plans created by configured + planning agents. +- Support users who want native OpenCode planning plus manual Plannotator + review through commands. + +## Non-Goals + +- Remove OpenCode plan-mode integration. +- Replace OpenCode's native plan mode. +- Make browser UI settings the source of truth for tool registration. +- Add a new `/plannotator-last-plan` command as part of the first phase. + +`/plannotator-last-plan` may still be useful later, but current Pi and OpenCode +feedback suggests `/plannotator-last` and `/plannotator-annotate` already cover +the most valuable manual entry points. + +## User-Facing Modes + +### `manual` + +Manual review mode. + +Behavior: + +- Do not register `submit_plan`. +- Do not inject Plannotator plan-submission prompts. +- Do not rewrite `plan_exit` or `todowrite`. +- Keep manual commands available: + - `/plannotator-last` + - `/plannotator-annotate` + - `/plannotator-review` + - `/plannotator-archive` +- Let OpenCode planning behave natively. + +This does not mean Plannotator is not part of planning. It means Plannotator +does not automatically interrupt planning. Users can still run +`/plannotator-last` on a plan message or `/plannotator-annotate` on a spec, +plan file, directory, or URL. + +### `plan-agent` + +Scoped automatic plan review mode. This should be the first migration default. + +Behavior: + +- Register `submit_plan`. +- Expose `submit_plan` only to configured planning agents. +- Hide or deny `submit_plan` for non-planning agents using OpenCode agent + permissions where possible. +- Also reject calls in `submit_plan.execute()` if `context.agent` is not in the + configured planning-agent list. +- Inject Plannotator planning guidance only for configured planning agents. +- Do not inject the lightweight "Plan Submission" reminder into arbitrary + primary agents. + +This mode is for users who want Plannotator integrated with OpenCode plan mode, +without letting `build` or other implementation agents call `submit_plan`. + +### `all-agents` + +Legacy broad automatic mode. + +Behavior: + +- Preserve today's broad behavior as much as practical. +- Register `submit_plan`. +- Allow primary agents to call `submit_plan`. +- Keep subagent behavior governed by the existing `primary_tools`/subagent + hiding mechanism unless explicitly overridden. + +This mode exists for users who intentionally rely on the current broad access +model. + +## Proposed Config + +OpenCode plugin-specific config should live in plugin tuple options, not a +top-level `plannotator` key. + +```json +{ + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +Fields: + +- `workflow`: `"manual" | "plan-agent" | "all-agents"` +- `planningAgents`: string array, default `["plan"]` + +Recommended defaults: + +```json +{ + "workflow": "plan-agent", + "planningAgents": ["plan"] +} +``` + +Environment variables may exist as temporary migration aids, but plugin tuple +options should be the durable interface. + +## OpenCode Source Findings + +External OpenCode source review answered the main integration questions: + +- Plugins can conditionally register tools at startup. If the plugin does not + return `tool.submit_plan`, the model does not see that tool. +- Plugin tool definitions do not have a native `visibleTo` field. +- OpenCode does filter tools through active agent permissions, and plugin tool + names can be controlled through the permission catchall. This allows + `agent..permission.submit_plan = "deny"` for non-planning agents. +- Plugin tool execution receives the invoking agent as `context.agent`. +- `client.session.prompt({ agent })` selects the agent for that specific prompt; + it does not generally mutate the TUI's selected agent. +- There is no plugin-native "approve but do not continue" primitive. To approve + without continuing, do not call `session.prompt()`. +- Top-level OpenCode config is strict, so plugin-specific settings belong in + plugin tuple options. + +## Implementation Design + +### Options Parsing + +Add an options schema near the OpenCode plugin entry: + +```ts +type WorkflowMode = "manual" | "plan-agent" | "all-agents"; + +interface PlannotatorOpenCodeOptions { + workflow?: WorkflowMode; + planningAgents?: string[]; +} +``` + +Normalize options once at plugin startup: + +- invalid or missing `workflow` -> `plan-agent` +- empty or missing `planningAgents` -> `["plan"]` +- agent names should be trimmed and deduplicated + +### Tool Registration + +Build the plugin return object conditionally: + +- `manual`: omit `tool.submit_plan` +- `plan-agent`: include `tool.submit_plan` +- `all-agents`: include `tool.submit_plan` + +This keeps OpenCode plan-mode integration working by default while removing the +broad non-plan-agent exposure that causes eager calls from implementation +agents. + +### Config Hook + +Mode-specific behavior: + +- `manual` + - do not add `submit_plan` to `experimental.primary_tools` + - do not mutate `agent.plan.permission.edit` + - do not add `submit_plan` permissions + +- `plan-agent` + - add `submit_plan` to `experimental.primary_tools` to keep it hidden from + subagents by default + - allow markdown editing for configured planning agents if needed + - deny `submit_plan` for known non-planning primary agents where OpenCode + agent config is available + +- `all-agents` + - preserve current primary-agent behavior + - keep `experimental.primary_tools` subagent hiding unless + `PLANNOTATOR_ALLOW_SUBAGENTS` is enabled + +### Prompt Hooks + +Mode-specific behavior: + +- `manual` + - no Plannotator prompt injection + - no `STRICTLY FORBIDDEN` replacement + - no `plan_exit` or `todowrite` description rewrites + +- `plan-agent` + - inject only for configured planning agents + - do not inject the lightweight reminder into other primary agents + - keep the `plan_exit` and `todowrite` tool-definition rewrites as mild + global compatibility adjustments because OpenCode's `tool.definition` hook + does not expose active-agent context + +- `all-agents` + - preserve current behavior, with any obvious bugs fixed + +The `plan_exit` and `todowrite` rewrites are not access control. `submit_plan` +visibility is controlled through OpenCode permission mutation where possible, +and correctness is enforced in `submit_plan.execute()` via `context.agent`. + +### Runtime Guard + +In `submit_plan.execute()`: + +- If workflow is `plan-agent` and `context.agent` is not in `planningAgents`, + return a clear rejection message instead of opening Plannotator. +- This guard is required even if permissions hide the tool, because permissions + are a visibility mechanism and runtime enforcement should still be explicit. + +Suggested message: + +```text +Plannotator is configured for plan-agent mode. submit_plan can only be called by: +plan + +Use /plannotator-last or /plannotator-annotate for manual review. +``` + +### Approval Handoff + +Approval should be decoupled from automatic implementation. + +Existing behavior sends `session.prompt()` when agent switching is enabled. For +future migration stages: + +- `manual`: not applicable because `submit_plan` is not registered +- `plan-agent`: default should be stay/stop; do not call `session.prompt()` + unless the user explicitly configured a continuation target +- `all-agents`: preserve existing behavior for compatibility + +The default agent-switch setting should be revisited separately. The current +fallback to `build` is still too opinionated for many OpenCode workflows, but +it is not part of the first migration stage. + +## Manual Features + +The following commands should be documented as first-class OpenCode workflows: + +### `/plannotator-last` + +Review or annotate the most recent assistant response. Useful when an OpenCode +agent produced a plan, explanation, design, or answer that the user wants to +review manually. + +### `/plannotator-annotate` + +Review arbitrary artifacts: + +- markdown files +- directories +- URLs +- specs or plan documents produced by tools such as OpenSpec + +This command is especially important for users whose planning process is +artifact-driven instead of chat-plan-driven. + +## Migration + +The first migration should narrow the default from broad primary-agent exposure +to plan-agent-only exposure. This keeps Plannotator integrated with OpenCode +plan mode while stopping `build` and other non-plan primary agents from seeing +or being nudged toward `submit_plan`. + +Default behavior with omitted config: + +```json +{ + "workflow": "plan-agent", + "planningAgents": ["plan"] +} +``` + +Existing users who want the current broad behavior should opt in: + +```json +{ + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "all-agents" + }] + ] +} +``` + +Users who want automatic review only from the plan agent: + +```json +{ + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +Users who want native OpenCode planning plus manual Plannotator review: + +```json +{ + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "manual" + }] + ] +} +``` + +## Phased Rollout + +### Stage 1: Narrow Default To `plan-agent` + +Goal: stop broad primary-agent exposure without removing OpenCode plan +integration. + +- Add plugin option parsing. +- Default to `plan-agent`. +- Keep `submit_plan` registered for automatic workflows. +- Omit `submit_plan` only in `manual`. +- Remove the generic `submit_plan` reminder for non-plan primary agents in the + default mode. +- Inject Plannotator planning guidance only for configured planning agents. +- Patch OpenCode permissions: + - `build.permission.submit_plan = "deny"` + - configured planning agents get `submit_plan = "allow"` + - user-configured non-planning primary agents get `submit_plan = "deny"` +- Add a runtime guard using `context.agent`. +- Keep the `plan_exit` and `todowrite` rewrites for `plan-agent` and + `all-agents`. +- Preserve current behavior under `all-agents`. +- Support `manual` as commands-only mode. + +### Stage 2: Documentation And Migration UX + +Goal: make the behavior change understandable. + +- Update OpenCode README and website docs. +- Document all three modes. +- Add migration snippets: + - old behavior: `workflow: "all-agents"` + - default plan-agent behavior: `workflow: "plan-agent"` + - commands-only: `workflow: "manual"` +- Update troubleshooting around why `build` cannot call `submit_plan` by + default. + +### Stage 3: Approval Semantics + +- Revisit default approval behavior. +- Make stay/stop the default for `plan-agent`. +- Preserve current implementation handoff under `all-agents`. +- Update UI copy to make continuation behavior explicit. + +### Stage 4: Optional Manual Plan Command + +Only if users ask for plan-specific manual semantics: + +- Add `/plannotator-last-plan`. +- Prefer latest assistant message from configured planning agents. +- Open plan-review UI instead of annotate-last mode. + +This should not block the first three phases. From 6ddd379123ce79294366bbc1bd8181d947098c02 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 21 Apr 2026 23:52:49 -0700 Subject: [PATCH 4/6] fix(opencode): restore paste backend and sandbox migration docs --- .../docs/getting-started/configuration.md | 2 + .../docs/guides/opencode-migration-0-19-1.md | 146 ++++++ .../src/content/docs/guides/opencode.md | 2 + apps/opencode-plugin/commands.ts | 22 +- apps/opencode-plugin/index.ts | 7 + docs/specs/opencode-manual-test-plan.md | 466 ++++++++++++++++++ scripts/clear-opencode-cache.sh | 79 +++ tests/manual/local/sandbox-opencode.sh | 140 +++++- 8 files changed, 839 insertions(+), 25 deletions(-) create mode 100644 apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md create mode 100644 docs/specs/opencode-manual-test-plan.md create mode 100755 scripts/clear-opencode-cache.sh diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md index d815a98e..9b1bf72c 100644 --- a/apps/marketing/src/content/docs/getting-started/configuration.md +++ b/apps/marketing/src/content/docs/getting-started/configuration.md @@ -75,6 +75,8 @@ To configure the workflow explicitly: Use `workflow: "manual"` for commands-only mode, or `workflow: "all-agents"` to restore the legacy behavior where primary agents can call `submit_plan`. Slash commands (`/plannotator-review`, `/plannotator-annotate`, `/plannotator-last`) require the CLI to be installed separately via the install script. +If you are upgrading from an older OpenCode install, see the [OpenCode 0.19.1 migration guide](/docs/guides/opencode-migration-0-19-1/). + ## Plan saving Approved and denied plans are saved to `~/.plannotator/plans/` by default. You can change the save directory or disable saving in the Plannotator UI settings (gear icon). diff --git a/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md b/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md new file mode 100644 index 00000000..317d2cb2 --- /dev/null +++ b/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md @@ -0,0 +1,146 @@ +--- +title: "OpenCode Migration (0.19.1)" +description: "What changes for existing OpenCode users in Plannotator 0.19.1, and how to keep or change the old behavior." +sidebar: + order: 6 +section: "Getting Started" +--- + +Plannotator `0.19.1` changes the default OpenCode workflow. + +Before `0.19.1`, OpenCode behavior was effectively broad automatic access: primary agents could see `submit_plan`, and users could run into cases where `build` or another non-planning agent reached for it. + +Starting in `0.19.1`, the default becomes `plan-agent`. + +## What changes on upgrade + +If you already use `@plannotator/opencode` and upgrade to `0.19.1` without adding any new config: + +- `submit_plan` stays available to OpenCode's planning agent, default `plan` +- `build` and other non-planning primary agents stop seeing or calling `submit_plan` by default +- the broad reminder that nudged non-plan primary agents toward `submit_plan` goes away +- `/plannotator-last`, `/plannotator-annotate`, `/plannotator-review`, and `/plannotator-archive` still work + +This is the new omitted-config default: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +## Why the default changed + +OpenCode feedback was consistent on two points: + +- users still want Plannotator integrated with OpenCode plan mode +- users do not want `submit_plan` exposed broadly enough that `build` or other implementation agents eagerly call it + +`plan-agent` is the compromise default: + +- it keeps OpenCode plan-mode integration +- it narrows `submit_plan` access to planning agents +- it avoids forcing everyone all the way into commands-only mode + +## If you want the old behavior + +If you want the pre-`0.19.1` broad behavior back, opt into `all-agents`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "all-agents" + }] + ] +} +``` + +Use this if you intentionally want primary agents other than `plan` to see and call `submit_plan`. + +## If you want commands only + +If you do not want automatic plan review at all, switch to `manual`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "manual" + }] + ] +} +``` + +In `manual` mode: + +- `submit_plan` is not registered +- OpenCode planning stays native +- you use Plannotator explicitly through: + - `/plannotator-last` + - `/plannotator-annotate` + - `/plannotator-review` + - `/plannotator-archive` + +## Recommended upgrade path + +Choose one of these: + +### Keep the new default + +Do nothing if you want: + +- Plannotator in OpenCode plan mode +- no broad `build` access to `submit_plan` + +### Restore the legacy model + +Set `workflow` to `all-agents` if your team already depends on broad primary-agent access. + +### Move to manual review + +Set `workflow` to `manual` if you prefer OpenCode's native planning flow and only want Plannotator when you invoke it yourself. + +## Common questions + +### Does this remove OpenCode plan integration? + +No. The default still keeps Plannotator integrated with OpenCode planning through the planning agent. + +### Does this break `/plannotator-last` or `/plannotator-annotate`? + +No. Manual commands continue to work across all workflow modes. + +### What if my planning agent is not named `plan`? + +Configure it explicitly: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "workflow": "plan-agent", + "planningAgents": ["planner"] + }] + ] +} +``` + +### I upgraded but OpenCode still looks stale + +Restart OpenCode after upgrading. If a cached plugin version is still being used, rerun the install script or clear the OpenCode cache and restart. + +See also: + +- [OpenCode guide](/docs/guides/opencode/) +- [Configuration](/docs/getting-started/configuration/) +- [Troubleshooting](/docs/guides/troubleshooting/) diff --git a/apps/marketing/src/content/docs/guides/opencode.md b/apps/marketing/src/content/docs/guides/opencode.md index ca3fc7c3..5f8fc894 100644 --- a/apps/marketing/src/content/docs/guides/opencode.md +++ b/apps/marketing/src/content/docs/guides/opencode.md @@ -8,6 +8,8 @@ section: "Getting Started" Plannotator integrates with OpenCode as an npm plugin. By default it makes `submit_plan` available to OpenCode's `plan` agent only, so OpenCode plan mode can use Plannotator without exposing the tool to `build`. +If you are upgrading from an older OpenCode setup, read the [0.19.1 migration guide](/docs/guides/opencode-migration-0-19-1/) first. + ## How the plugin works The OpenCode plugin (`@plannotator/opencode`) hooks into OpenCode's plugin system: diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 1f2e474d..57a4d41b 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -28,6 +28,16 @@ import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { statSync } from "fs"; import path from "path"; +function resolveLocalAnnotatePath(input: string, projectRoot: string): string { + const trimmed = input.trim(); + const unquoted = + trimmed.length >= 2 + && ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) + ? trimmed.slice(1, -1) + : trimmed; + return path.resolve(projectRoot, unquoted); +} + /** Shared dependencies injected by the plugin */ export interface CommandDeps { client: any; @@ -35,6 +45,7 @@ export interface CommandDeps { reviewHtmlContent: string; getSharingEnabled: () => Promise; getShareBaseUrl: () => string | undefined; + getPasteApiUrl: () => string | undefined; directory?: string; } @@ -148,7 +159,7 @@ export async function handleAnnotateCommand( event: any, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps; // @ts-ignore - Event properties contain arguments let filePath = event.properties?.arguments || event.arguments || ""; @@ -185,7 +196,7 @@ export async function handleAnnotateCommand( sourceInfo = filePath; } else { const projectRoot = directory || process.cwd(); - const resolvedArg = path.resolve(projectRoot, filePath); + const resolvedArg = resolveLocalAnnotatePath(filePath, projectRoot); let isFolder = false; try { @@ -254,6 +265,7 @@ export async function handleAnnotateCommand( sourceInfo, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent, onReady: handleAnnotateServerReady, }); @@ -297,7 +309,7 @@ export async function handleAnnotateLastCommand( event: any, deps: CommandDeps ): Promise { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; @@ -343,6 +355,7 @@ export async function handleAnnotateLastCommand( mode: "annotate-last", sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent, onReady: handleAnnotateServerReady, }); @@ -362,7 +375,7 @@ export async function handleArchiveCommand( event: any, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; client.app.log({ level: "info", message: "Opening plan archive..." }); @@ -372,6 +385,7 @@ export async function handleArchiveCommand( mode: "archive", sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent, onReady: handleServerReady, }); diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index fef1c199..91e20df2 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -218,6 +218,10 @@ export const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpe return process.env.PLANNOTATOR_SHARE_URL || undefined; } + function getPasteApiUrl(): string | undefined { + return process.env.PLANNOTATOR_PASTE_URL || undefined; + } + function getPlanTimeoutSeconds(): number | null { const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim(); if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS; @@ -367,6 +371,7 @@ Do NOT proceed with implementation until your plan is approved.`); reviewHtmlContent: getReviewHtml(), getSharingEnabled, getShareBaseUrl, + getPasteApiUrl, directory: ctx.directory, }; @@ -409,6 +414,7 @@ Do NOT proceed with implementation until your plan is approved.`); reviewHtmlContent: getReviewHtml(), getSharingEnabled, getShareBaseUrl, + getPasteApiUrl, directory: ctx.directory, }; @@ -461,6 +467,7 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo origin: "opencode", sharingEnabled, shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent: getPlanHtml(), opencodeClient: ctx.client, onReady: async (url, isRemote, port) => { diff --git a/docs/specs/opencode-manual-test-plan.md b/docs/specs/opencode-manual-test-plan.md new file mode 100644 index 00000000..659ba252 --- /dev/null +++ b/docs/specs/opencode-manual-test-plan.md @@ -0,0 +1,466 @@ +# OpenCode Manual Test Plan + +## Purpose + +Validate the OpenCode changes on this branch end to end: + +- workflow-gated `submit_plan` access +- prompt and tool-definition behavior across workflow modes +- manual command behavior, including folder annotation +- migration behavior for existing OpenCode users + +This plan is for local testing. Do not publish the plugin to npm for these checks. + +## Scope + +In scope: + +- `workflow: "plan-agent"` default behavior +- `workflow: "manual"` commands-only behavior +- `workflow: "all-agents"` legacy broad behavior +- plan-agent prompt injection and access control +- `submit_plan` runtime rejection for the wrong agent +- `/plannotator-annotate` support for files, folders, and URLs +- `/plannotator-last` basic behavior +- doc examples and migration snippets + +Out of scope for this pass: + +- deep browser UI QA inside the Plannotator app itself +- unrelated OpenCode plugin behavior +- approval-semantics redesign beyond current behavior + +## Test Environment + +Recommended environment: + +- local checkout of this repo on the branch under test +- local OpenCode environment +- a throwaway test project for OpenCode sessions +- browser available locally + +Use a local file/path plugin. npm publishing is not required. + +Supported local setups: + +1. Put the plugin in `.opencode/plugins/` inside the test project. +2. Point `opencode.json` at a relative or absolute local plugin path. + +## Recommended Sandbox Runs + +The local OpenCode sandbox can now exercise all three workflow modes. + +From the repo root: + +```bash +bash tests/manual/local/sandbox-opencode.sh --workflow plan-agent --keep +bash tests/manual/local/sandbox-opencode.sh --workflow manual --keep +bash tests/manual/local/sandbox-opencode.sh --workflow all-agents --keep +``` + +Optional custom planning agent run: + +```bash +bash tests/manual/local/sandbox-opencode.sh --workflow plan-agent --planning-agents planner --keep +``` + +Use `--keep` while testing so the generated sandbox directory and `opencode.json` +remain available for inspection after OpenCode exits. + +## Local Plugin Setup + +### Option A: Auto-loaded project plugin + +Create this structure in the project you will open with OpenCode: + +```text +your-test-project/ + .opencode/ + package.json + plugins/ + plannotator.ts +``` + +`.opencode/package.json`: + +```json +{ + "dependencies": { + "@opencode-ai/plugin": "*" + } +} +``` + +`plannotator.ts` can re-export the local plugin entry from this repo, or you can point straight at the repo file with Option B. + +### Option B: Local path in `opencode.json` + +Use the plugin tuple form so workflow options can be changed without editing code: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +Use the same pattern for `manual` and `all-agents` test runs. + +## Test Data + +Prepare a small test workspace with: + +- one markdown file such as `notes/plan.md` +- one HTML file such as `notes/spec.html` +- one folder containing markdown or HTML files such as `specs/` +- one folder with no markdown or HTML files for negative testing +- one simple coding task that naturally triggers OpenCode planning + +## Test Matrix + +| Area | `manual` | `plan-agent` | `all-agents` | +|---|---|---|---| +| `submit_plan` registered | No | Yes | Yes | +| `plan` can call `submit_plan` | No | Yes | Yes | +| `build` can call `submit_plan` | No | No | Yes | +| full planning prompt injected for `plan` | No | Yes | Yes | +| generic reminder injected for non-plan primary agents | No | No | Yes | +| `plan_exit` / `todowrite` rewrites active | No | Yes | Yes | +| `/plannotator-last` works | Yes | Yes | Yes | +| `/plannotator-annotate` works | Yes | Yes | Yes | + +## Test Cases + +### 1. Plugin Loads Locally + +Setup: + +- Start OpenCode in the test project with the local plugin configuration. + +Verify: + +- OpenCode starts successfully. +- The plugin loads without requiring an npm publish. +- Slash commands and tool behavior match the configured workflow. + +Expected result: + +- No startup failure caused by plugin resolution. + +### 2. Default Workflow Is `plan-agent` + +Setup: + +- Omit plugin options and load the local plugin. + +Verify: + +- OpenCode behaves as if configured with: + +```json +{ + "workflow": "plan-agent", + "planningAgents": ["plan"] +} +``` + +Expected result: + +- `submit_plan` is available to `plan`. +- `build` does not get broad access by default. + +### 3. `manual` Mode Removes Automatic Planning Integration + +Setup: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { + "workflow": "manual" + }] + ] +} +``` + +Steps: + +1. Start a session and use OpenCode plan mode. +2. Inspect the available tools for `plan` and `build`. +3. Ask the agent for a plan. +4. Run `/plannotator-last`. +5. Run `/plannotator-annotate notes/plan.md`. + +Verify: + +- `submit_plan` is not registered. +- No Plannotator planning prompt is injected. +- `plan_exit` and `todowrite` descriptions are not rewritten. +- Manual commands still work. + +Expected result: + +- OpenCode planning remains native. +- Plannotator is only used when the user manually invokes it. + +### 4. `plan-agent` Mode Scopes `submit_plan` To Planning Agents + +Setup: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { + "workflow": "plan-agent", + "planningAgents": ["plan"] + }] + ] +} +``` + +Steps: + +1. Start a session with OpenCode plan mode. +2. Trigger planning with the `plan` agent. +3. Inspect the `plan` agent tool list. +4. Inspect the `build` agent tool list. +5. Ask `plan` to produce and submit a plan. + +Verify: + +- `plan` can see and call `submit_plan`. +- `build` cannot see or use `submit_plan` in the normal tool list. +- The full Plannotator planning prompt is injected for `plan`. +- The generic reminder is not injected into unrelated primary agents. +- `plan_exit` and `todowrite` rewrites still appear. + +Expected result: + +- OpenCode plan mode remains integrated with Plannotator. +- Broad primary-agent exposure is gone by default. + +### 5. Runtime Guard Rejects Wrong-Agent Calls + +Setup: + +- Stay in `plan-agent` mode. + +Steps: + +1. Try to force a `submit_plan` call from `build` or another non-planning agent. +2. If direct invocation is possible through a prompt or tool replay path, execute it. + +Verify: + +- The call is rejected with a clear message. +- Plannotator does not open. +- The rejection points users toward `/plannotator-last`, `/plannotator-annotate`, or `workflow: "all-agents"`. + +Expected result: + +- Wrong-agent invocation fails safely even if visibility checks are bypassed. + +### 6. Custom Planning Agent Works + +Setup: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { + "workflow": "plan-agent", + "planningAgents": ["planner"] + }] + ] +} +``` + +Steps: + +1. Configure or use an OpenCode agent named `planner`. +2. Start a session that routes planning through `planner`. +3. Inspect `planner` and `build`. + +Verify: + +- `planner` gets `submit_plan`. +- `build` is denied. +- Planning prompt injection follows `planner`, not `plan`. + +Expected result: + +- Workflow gating tracks the configured planning-agent list. + +### 7. `all-agents` Preserves Legacy Broad Behavior + +Setup: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { + "workflow": "all-agents" + }] + ] +} +``` + +Steps: + +1. Start a session. +2. Inspect `plan` and `build`. +3. Ask a non-plan primary agent to produce a plan. + +Verify: + +- `submit_plan` remains broadly available to primary agents. +- The generic plan reminder is still injected for non-plan primary agents. +- `plan_exit` and `todowrite` rewrites remain active. + +Expected result: + +- Existing users can opt back into the old model. + +### 8. `/plannotator-annotate` Supports Markdown Files + +Steps: + +1. Run `/plannotator-annotate notes/plan.md`. + +Verify: + +- The annotation UI opens for the markdown file. +- File resolution works as expected. + +Expected result: + +- Existing annotate-file behavior still works. + +### 9. `/plannotator-annotate` Supports HTML Files + +Steps: + +1. Run `/plannotator-annotate notes/spec.html`. + +Verify: + +- The file is converted and opened in the annotation UI. +- Oversized HTML files still fail with a useful error. + +Expected result: + +- HTML annotation remains intact. + +### 10. `/plannotator-annotate` Supports Folders + +Steps: + +1. Run `/plannotator-annotate specs/`. +2. Repeat with `@specs/`. + +Verify: + +- The annotation UI opens in folder mode. +- The leading `@` prefix is tolerated. +- The folder path is passed through correctly. + +Expected result: + +- Folder annotation works through the OpenCode plugin command path. + +### 11. `/plannotator-annotate` Rejects Invalid Folders + +Steps: + +1. Run `/plannotator-annotate empty-folder/` where the folder contains no markdown or HTML files. + +Verify: + +- The command fails with a clear error instead of opening the UI. + +Expected result: + +- Invalid folder input is rejected cleanly. + +### 12. `/plannotator-annotate` Supports URLs + +Steps: + +1. Run `/plannotator-annotate https://example.com`. + +Verify: + +- The page is fetched and opened in the annotation UI. + +Expected result: + +- URL annotation still works after the command changes. + +### 13. `/plannotator-last` Still Works In Every Workflow + +Steps: + +1. Have the agent produce a normal message. +2. Run `/plannotator-last`. + +Verify: + +- The last assistant message opens for annotation. +- Returned feedback is sent back into the session. + +Expected result: + +- Manual review of the latest assistant output remains available regardless of workflow mode. + +### 14. Migration Docs Match Runtime Behavior + +Files to spot-check: + +- `apps/opencode-plugin/README.md` +- `apps/marketing/src/content/docs/guides/opencode.md` +- `apps/marketing/src/content/docs/getting-started/configuration.md` +- `apps/marketing/src/content/docs/getting-started/installation.md` +- `apps/marketing/src/content/docs/guides/troubleshooting.md` + +Verify: + +- Docs say the default is `plan-agent`. +- Docs show how to opt into `all-agents`. +- Docs show how to opt into `manual`. +- Docs say `/plannotator-annotate` supports folders. +- Troubleshooting explains why `build` cannot call `submit_plan` by default. + +Expected result: + +- Documentation matches actual plugin behavior. + +## Regression Checks + +Before signoff, confirm: + +- `submit_plan` still opens the browser UI and completes a normal review cycle. +- approved plans still return success to the agent +- denied plans still return revision feedback +- plan-agent mode does not break OpenCode plan mode +- manual mode does not accidentally register `submit_plan` +- all-agents mode does not accidentally deny `build` + +## Signoff Criteria + +This change is ready if: + +- all three workflow modes behave as documented +- default behavior is `plan-agent` +- non-planning agents no longer get eager `submit_plan` exposure by default +- manual commands remain strong first-class paths +- folder annotation works through the OpenCode plugin +- migration docs are accurate diff --git a/scripts/clear-opencode-cache.sh b/scripts/clear-opencode-cache.sh new file mode 100755 index 00000000..aa61695e --- /dev/null +++ b/scripts/clear-opencode-cache.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: clear-opencode-cache.sh [--dry-run] [--help] + +Clears OpenCode cache directories and related Bun package caches so OpenCode +and the Plannotator OpenCode plugin are reloaded from a clean state. + +Options: + --dry-run Show which paths would be removed without deleting them + -h, --help Show this help and exit +EOF +} + +dry_run=0 + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +xdg_cache_home="${XDG_CACHE_HOME:-$HOME/.cache}" +bun_cache_home="${BUN_INSTALL_CACHE_DIR:-$HOME/.bun/install/cache}" + +paths=( + "$xdg_cache_home/opencode" + "$bun_cache_home/@opencode-ai" + "$bun_cache_home/@plannotator" +) + +echo "OpenCode cache cleanup" +echo "" + +removed_any=0 + +for path in "${paths[@]}"; do + if [ -e "$path" ]; then + if [ "$dry_run" -eq 1 ]; then + echo "Would remove: $path" + else + rm -rf "$path" + echo "Removed: $path" + fi + removed_any=1 + else + echo "Not found: $path" + fi +done + +echo "" + +if [ "$dry_run" -eq 1 ]; then + if [ "$removed_any" -eq 1 ]; then + echo "Dry run complete." + else + echo "Dry run complete. No matching cache paths were found." + fi +else + if [ "$removed_any" -eq 1 ]; then + echo "OpenCode cache cleared." + else + echo "No matching cache paths were found." + fi +fi diff --git a/tests/manual/local/sandbox-opencode.sh b/tests/manual/local/sandbox-opencode.sh index 0de4ceb1..ae4c1763 100755 --- a/tests/manual/local/sandbox-opencode.sh +++ b/tests/manual/local/sandbox-opencode.sh @@ -2,23 +2,29 @@ # Sandbox script for testing Plannotator OpenCode plugin locally # # Usage: -# ./sandbox-opencode.sh [--disable-sharing] [--keep] [--no-git] +# ./sandbox-opencode.sh [--workflow MODE] [--planning-agents AGENTS] [--disable-sharing] [--keep] [--no-git] # # Options: +# --workflow MODE Plugin workflow to test: manual | plan-agent | all-agents +# Default: plan-agent +# --planning-agents Comma-separated planning agent names for plan-agent mode +# Default: plan # --disable-sharing Create opencode.json with "share": "disabled" to test # the sharing disable feature without env var pollution # --keep Don't clean up sandbox on exit (for debugging) # --no-git Don't initialize git repo (tests non-git fallback) # # What it does: -# 1. Builds the plugin (ensures latest code) -# 2. Creates a temp directory with git repo -# 3. Creates sample files with uncommitted changes (for /plannotator-review) -# 4. Sets up the local plugin -# 5. Launches OpenCode in the sandbox +# 1. Clears OpenCode-related caches +# 2. Builds the plugin (ensures latest code) +# 3. Creates a temp directory with git repo +# 4. Creates sample files with uncommitted changes (for /plannotator-review) +# 5. Writes workflow-specific OpenCode config +# 6. Sets up the local plugin +# 6. Launches OpenCode in the sandbox # # To test: -# - Plan mode: Ask the agent to plan something, it should call submit_plan +# - Plan mode behavior varies by --workflow # - Code review: Run /plannotator-review to review the sample changes set -e @@ -26,13 +32,33 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" PLUGIN_DIR="$PROJECT_ROOT/apps/opencode-plugin" +CLEAR_CACHE_SCRIPT="$PROJECT_ROOT/scripts/clear-opencode-cache.sh" +PLUGIN_LOADER_RELATIVE_PATH="./.opencode/plannotator.ts" # Parse CLI flags +WORKFLOW="plan-agent" +PLANNING_AGENTS="plan" DISABLE_SHARING=false KEEP_SANDBOX=false NO_GIT=false -for arg in "$@"; do - case $arg in +while [ $# -gt 0 ]; do + case "$1" in + --workflow) + if [ -z "${2:-}" ]; then + echo "--workflow requires an argument" >&2 + exit 1 + fi + WORKFLOW="$2" + shift 2 + ;; + --planning-agents) + if [ -z "${2:-}" ]; then + echo "--planning-agents requires an argument" >&2 + exit 1 + fi + PLANNING_AGENTS="$2" + shift 2 + ;; --disable-sharing) DISABLE_SHARING=true shift @@ -45,12 +71,51 @@ for arg in "$@"; do NO_GIT=true shift ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; esac done +case "$WORKFLOW" in + manual|plan-agent|all-agents) ;; + *) + echo "Invalid --workflow value: $WORKFLOW" >&2 + echo "Expected one of: manual, plan-agent, all-agents" >&2 + exit 1 + ;; +esac + +planning_agents_json() { + local raw="$1" + local IFS=',' + local parts=() + local item + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + if [ -n "$item" ]; then + parts+=("\"$item\"") + fi + done + + if [ ${#parts[@]} -eq 0 ]; then + parts+=("\"plan\"") + fi + + local IFS=', ' + printf '[%s]' "${parts[*]}" +} + echo "=== Plannotator OpenCode Sandbox ===" echo "" +# Clear OpenCode caches so the sandbox always starts from a fresh plugin state +echo "Clearing OpenCode caches..." +bash "$CLEAR_CACHE_SCRIPT" +echo "" + # Build the plugin (includes building dependencies) echo "Building plugin..." cd "$PROJECT_ROOT" @@ -1538,11 +1603,11 @@ echo "" # Set up local plugin via loader file echo "Setting up local plugin..." -mkdir -p .opencode/plugins +mkdir -p .opencode -# Create a loader file that re-exports from the source -# OpenCode only loads top-level .ts/.js files in the plugins directory -cat > .opencode/plugins/plannotator.ts << EOF +# Create a loader file that re-exports from the source. +# The loader is referenced from opencode.json so we can pass plugin options. +cat > .opencode/plannotator.ts << EOF // Loader for local Plannotator plugin development export * from "$PLUGIN_DIR/index.ts"; EOF @@ -1557,19 +1622,39 @@ cp "$PLUGIN_DIR/commands/"*.md ~/.config/opencode/commands/ 2>/dev/null || true echo "" -# Create opencode.json config if --disable-sharing was passed -if [ "$DISABLE_SHARING" = true ]; then - echo "Creating opencode.json with sharing disabled..." - cat > opencode.json << 'EOF' +# Create opencode.json with workflow-specific plugin config +echo "Writing opencode.json for workflow: $WORKFLOW" +PLUGIN_CONFIG=$(cat < opencode.json << EOF { - "share": "disabled" + "\$schema": "https://opencode.ai/config.json", + "plugin": $PLUGIN_CONFIG$( + if [ "$DISABLE_SHARING" = true ]; then + printf ',\n "share": "disabled"' + fi + ) } EOF -fi echo "=== Sandbox Ready ===" echo "" echo "Directory: $SANDBOX_DIR" +echo "Workflow: $WORKFLOW" +if [ "$WORKFLOW" = "plan-agent" ]; then + echo "Planning agents: $PLANNING_AGENTS" +fi if [ "$NO_GIT" = true ]; then echo "Git: DISABLED (--no-git)" else @@ -1582,9 +1667,22 @@ else fi echo "" echo "To test:" -echo " 1. Plan mode: Ask the agent to plan something" +case "$WORKFLOW" in + manual) + echo " 1. Plan mode: ask for a plan and confirm submit_plan is not available" + echo " 2. Manual review: run /plannotator-last or /plannotator-annotate" + ;; + plan-agent) + echo " 1. Plan mode: ask the plan agent to produce a plan and call submit_plan" + echo " 2. Confirm build does not get submit_plan access" + ;; + all-agents) + echo " 1. Plan mode: ask a primary agent to produce a plan and call submit_plan" + echo " 2. Confirm broad primary-agent access is restored" + ;; +esac if [ "$NO_GIT" = false ]; then - echo " 2. Code review: Run /plannotator-review" + echo " 3. Code review: Run /plannotator-review" fi echo "" echo "Launching OpenCode..." From 77ace00fba05f13c8c02ec2909a97eeda7c15e2b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 22 Apr 2026 11:28:17 -0700 Subject: [PATCH 5/6] fix(opencode): stabilize planning and folder drafts --- .../docs/getting-started/configuration.md | 2 +- .../docs/guides/opencode-migration-0-19-1.md | 7 +- .../src/content/docs/guides/opencode.md | 4 +- apps/opencode-plugin/README.md | 2 +- apps/opencode-plugin/commands.test.ts | 126 ++++++++++++++++++ apps/opencode-plugin/workflow.test.ts | 26 +++- apps/opencode-plugin/workflow.ts | 12 +- apps/pi-extension/server/serverAnnotate.ts | 8 +- packages/server/annotate.ts | 8 +- tests/manual/local/sandbox-opencode.sh | 28 +++- 10 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 apps/opencode-plugin/commands.test.ts diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md index 9b1bf72c..a88c5c17 100644 --- a/apps/marketing/src/content/docs/getting-started/configuration.md +++ b/apps/marketing/src/content/docs/getting-started/configuration.md @@ -73,7 +73,7 @@ To configure the workflow explicitly: } ``` -Use `workflow: "manual"` for commands-only mode, or `workflow: "all-agents"` to restore the legacy behavior where primary agents can call `submit_plan`. Slash commands (`/plannotator-review`, `/plannotator-annotate`, `/plannotator-last`) require the CLI to be installed separately via the install script. +Use `workflow: "manual"` for commands-only mode, or `workflow: "all-agents"` to restore the legacy behavior where primary agents can call `submit_plan`. In `plan-agent` mode, any names listed in `planningAgents` are added alongside OpenCode's built-in `plan` agent. Slash commands (`/plannotator-review`, `/plannotator-annotate`, `/plannotator-last`) require the CLI to be installed separately via the install script. If you are upgrading from an older OpenCode install, see the [OpenCode 0.19.1 migration guide](/docs/guides/opencode-migration-0-19-1/). diff --git a/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md b/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md index 317d2cb2..db49f4c1 100644 --- a/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md +++ b/apps/marketing/src/content/docs/guides/opencode-migration-0-19-1.md @@ -17,6 +17,7 @@ Starting in `0.19.1`, the default becomes `plan-agent`. If you already use `@plannotator/opencode` and upgrade to `0.19.1` without adding any new config: - `submit_plan` stays available to OpenCode's planning agent, default `plan` +- any agents you list in `planningAgents` are added alongside `plan` - `build` and other non-planning primary agents stop seeing or calling `submit_plan` by default - the broad reminder that nudged non-plan primary agents toward `submit_plan` goes away - `/plannotator-last`, `/plannotator-annotate`, `/plannotator-review`, and `/plannotator-archive` still work @@ -44,8 +45,8 @@ OpenCode feedback was consistent on two points: `plan-agent` is the compromise default: -- it keeps OpenCode plan-mode integration -- it narrows `submit_plan` access to planning agents +- it keeps OpenCode plan-mode integration through the built-in `plan` agent +- it narrows `submit_plan` access to `plan` plus any extra planning agents you configure - it avoids forcing everyone all the way into commands-only mode ## If you want the old behavior @@ -121,7 +122,7 @@ No. Manual commands continue to work across all workflow modes. ### What if my planning agent is not named `plan`? -Configure it explicitly: +Add it explicitly. OpenCode's built-in `plan` agent stays enabled in `plan-agent` mode: ```json { diff --git a/apps/marketing/src/content/docs/guides/opencode.md b/apps/marketing/src/content/docs/guides/opencode.md index 5f8fc894..f68d98ae 100644 --- a/apps/marketing/src/content/docs/guides/opencode.md +++ b/apps/marketing/src/content/docs/guides/opencode.md @@ -14,7 +14,7 @@ If you are upgrading from an older OpenCode setup, read the [0.19.1 migration gu The OpenCode plugin (`@plannotator/opencode`) hooks into OpenCode's plugin system: -1. The plugin registers a `submit_plan` tool for configured planning agents +1. The plugin registers a `submit_plan` tool for OpenCode's built-in `plan` agent and any extra planning agents you configure 2. When `submit_plan` is called with a plan, Plannotator starts a local server and opens the browser 3. The user reviews and annotates the plan 4. On approval, the plugin returns a success response to the agent @@ -24,7 +24,7 @@ The OpenCode plugin (`@plannotator/opencode`) hooks into OpenCode's plugin syste OpenCode support has three explicit modes: -- **`plan-agent`** (default): `submit_plan` is available to configured planning agents only. The default planning agent is `plan`. +- **`plan-agent`** (default): `submit_plan` is available to OpenCode's built-in `plan` agent plus any extra agents listed in `planningAgents`. - **`manual`**: `submit_plan` is not registered. Use `/plannotator-last`, `/plannotator-annotate`, `/plannotator-review`, and `/plannotator-archive` when you want Plannotator. - **`all-agents`**: legacy broad behavior. Primary agents can see and call `submit_plan`. diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index 4e0e4084..aa931a6c 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -40,7 +40,7 @@ Restart OpenCode. By default, the `submit_plan` tool is available to OpenCode's Plannotator supports three OpenCode workflows: -- **`plan-agent`** (default): `submit_plan` is available to configured planning agents only. This keeps Plannotator integrated with OpenCode plan mode without nudging `build` to call it. +- **`plan-agent`** (default): `submit_plan` is available to OpenCode's built-in `plan` agent plus any extra agents listed in `planningAgents`. This keeps Plannotator integrated with OpenCode plan mode without nudging `build` to call it. - **`manual`**: `submit_plan` is not registered. Use `/plannotator-last`, `/plannotator-annotate`, `/plannotator-review`, and `/plannotator-archive` when you want Plannotator. - **`all-agents`**: legacy broad behavior. Primary agents can see and call `submit_plan`. diff --git a/apps/opencode-plugin/commands.test.ts b/apps/opencode-plugin/commands.test.ts new file mode 100644 index 00000000..678e0fbe --- /dev/null +++ b/apps/opencode-plugin/commands.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; + +const startAnnotateServerMock = mock(async (_options: any) => ({ + waitForDecision: async () => ({ feedback: "", annotations: [] }), + stop: () => {}, +})); + +mock.module("@plannotator/server/annotate", () => ({ + startAnnotateServer: startAnnotateServerMock, + handleAnnotateServerReady: () => {}, +})); + +const { handleAnnotateCommand, handleAnnotateLastCommand } = await import("./commands"); + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "plannotator-opencode-commands-")); + tempDirs.push(dir); + return dir; +} + +function makeDeps() { + return { + client: { + app: { + log: mock((_entry: unknown) => {}), + }, + session: { + prompt: mock(async (_input: unknown) => {}), + messages: mock(async (_input: unknown) => ({ data: [] })), + }, + }, + htmlContent: "", + reviewHtmlContent: "", + getSharingEnabled: async () => true, + getShareBaseUrl: () => "https://share.example.test", + getPasteApiUrl: () => "https://paste.example.test", + directory: undefined as string | undefined, + }; +} + +afterEach(() => { + startAnnotateServerMock.mockClear(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("handleAnnotateCommand", () => { + test("strips wrapping quotes from HTML paths and forwards pasteApiUrl", async () => { + const projectRoot = makeTempDir(); + const docsDir = path.join(projectRoot, "docs"); + mkdirSync(docsDir, { recursive: true }); + const htmlPath = path.join(docsDir, "Design Spec.html"); + writeFileSync(htmlPath, "

Design Spec

Body

"); + + const deps = makeDeps(); + deps.directory = projectRoot; + + await handleAnnotateCommand( + { properties: { arguments: "\"docs/Design Spec.html\"" } }, + deps, + ); + + expect(startAnnotateServerMock).toHaveBeenCalledTimes(1); + const options = startAnnotateServerMock.mock.calls[0]?.[0]; + expect(options.filePath).toBe(htmlPath); + expect(options.mode).toBe("annotate"); + expect(options.pasteApiUrl).toBe("https://paste.example.test"); + expect(options.shareBaseUrl).toBe("https://share.example.test"); + expect(options.markdown).toContain("Design Spec"); + }); + + test("supports quoted folder paths and opens annotate-folder mode", async () => { + const projectRoot = makeTempDir(); + const folderPath = path.join(projectRoot, "docs", "Specs Folder"); + mkdirSync(folderPath, { recursive: true }); + writeFileSync(path.join(folderPath, "plan.md"), "# Plan\n"); + + const deps = makeDeps(); + deps.directory = projectRoot; + + await handleAnnotateCommand( + { properties: { arguments: "\"docs/Specs Folder\"" } }, + deps, + ); + + expect(startAnnotateServerMock).toHaveBeenCalledTimes(1); + const options = startAnnotateServerMock.mock.calls[0]?.[0]; + expect(options.filePath).toBe(folderPath); + expect(options.folderPath).toBe(folderPath); + expect(options.mode).toBe("annotate-folder"); + expect(options.pasteApiUrl).toBe("https://paste.example.test"); + expect(options.markdown).toBe(""); + }); +}); + +describe("handleAnnotateLastCommand", () => { + test("forwards pasteApiUrl for annotate-last sessions", async () => { + const deps = makeDeps(); + deps.client.session.messages = mock(async (_input: unknown) => ({ + data: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Latest assistant message" }], + }, + ], + })); + + await handleAnnotateLastCommand( + { properties: { sessionID: "session-123" } }, + deps, + ); + + expect(startAnnotateServerMock).toHaveBeenCalledTimes(1); + const options = startAnnotateServerMock.mock.calls[0]?.[0]; + expect(options.mode).toBe("annotate-last"); + expect(options.filePath).toBe("last-message"); + expect(options.pasteApiUrl).toBe("https://paste.example.test"); + expect(options.markdown).toBe("Latest assistant message"); + }); +}); diff --git a/apps/opencode-plugin/workflow.test.ts b/apps/opencode-plugin/workflow.test.ts index ef5a7493..4280525e 100644 --- a/apps/opencode-plugin/workflow.test.ts +++ b/apps/opencode-plugin/workflow.test.ts @@ -24,16 +24,16 @@ describe("normalizeWorkflowOptions", () => { expect(options.workflow).toBe("plan-agent"); }); - test("trims and deduplicates planning agents", () => { + test("always includes plan and adds trimmed unique planning agents", () => { const options = normalizeWorkflowOptions({ workflow: "plan-agent", - planningAgents: [" plan ", "", "planner", "plan", 123], + planningAgents: [" planner ", "", "planner", 123], }); expect(options.planningAgents).toEqual(["plan", "planner"]); }); - test("uses the default planning agent when the configured list is empty", () => { + test("keeps the built-in plan agent when the configured list is empty", () => { const options = normalizeWorkflowOptions({ workflow: "plan-agent", planningAgents: ["", " "], @@ -115,7 +115,7 @@ describe("applyWorkflowConfig", () => { expect(config.agent.build.permission.submit_plan).toBe("deny"); }); - test("plan-agent mode preserves user agent fields and existing permissions", () => { + test("plan-agent mode preserves user agent fields and adds custom planning agents", () => { const config: any = { agent: { planner: { @@ -147,6 +147,24 @@ describe("applyWorkflowConfig", () => { "*": "deny", "*.md": "allow", }); + expect(config.agent.plan.permission.submit_plan).toBe("allow"); + }); + + test("plan-agent mode treats planningAgents as additive to built-in plan", () => { + const config: any = {}; + + applyWorkflowConfig( + config, + normalizeWorkflowOptions({ + workflow: "plan-agent", + planningAgents: ["planner"], + }), + false, + ); + + expect(config.agent.plan.permission.submit_plan).toBe("allow"); + expect(config.agent.planner.permission.submit_plan).toBe("allow"); + expect(config.agent.build.permission.submit_plan).toBe("deny"); }); test("plan-agent mode denies user-configured non-planning primary agents", () => { diff --git a/apps/opencode-plugin/workflow.ts b/apps/opencode-plugin/workflow.ts index 1dd43269..0679c294 100644 --- a/apps/opencode-plugin/workflow.ts +++ b/apps/opencode-plugin/workflow.ts @@ -16,6 +16,7 @@ export interface NormalizedWorkflowOptions { const WORKFLOWS = new Set(["manual", "plan-agent", "all-agents"]); const DEFAULT_WORKFLOW: WorkflowMode = "plan-agent"; const DEFAULT_PLANNING_AGENTS = ["plan"]; +const BUILTIN_PLAN_AGENT = "plan"; type AgentConfig = { mode?: string; @@ -51,10 +52,12 @@ export function normalizeWorkflowOptions( } function normalizePlanningAgents(value: unknown): string[] { - if (!Array.isArray(value)) return DEFAULT_PLANNING_AGENTS; - const seen = new Set(); - const agents: string[] = []; + const agents: string[] = [BUILTIN_PLAN_AGENT]; + seen.add(BUILTIN_PLAN_AGENT); + + if (!Array.isArray(value)) return agents; + for (const item of value) { if (typeof item !== "string") continue; const trimmed = item.trim(); @@ -63,7 +66,7 @@ function normalizePlanningAgents(value: unknown): string[] { agents.push(trimmed); } - return agents.length > 0 ? agents : DEFAULT_PLANNING_AGENTS; + return agents; } export function isPlanningAgent( @@ -180,4 +183,3 @@ function isPrimaryCapableAgent(agent: AgentConfig, allowSubagents: boolean): boo if (mode === "subagent") return allowSubagents; return mode === "primary" || mode === "all" || !agent.mode; } - diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 7b1e1177..617d474c 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -65,8 +65,12 @@ export async function startAnnotateServer(options: { resolveDecision = r; }); - // Draft key for annotation persistence - const draftKey = contentHash(options.markdown); + // Folder annotation has no stable markdown body, so key drafts by folder path instead. + const draftSource = + options.mode === "annotate-folder" && options.folderPath + ? `folder:${resolvePath(options.folderPath)}` + : options.markdown; + const draftKey = contentHash(draftSource); // Detect repo info (cached for this session) const repoInfo = getRepoInfo(); diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 13865f2a..0daa9fcf 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -19,7 +19,7 @@ import { handleDoc, handleFileBrowserFiles, handleObsidianVaults, handleObsidian import { contentHash, deleteDraft } from "./draft"; import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; -import { dirname } from "path"; +import { dirname, resolve as resolvePath } from "path"; import { isWSL } from "./browser"; // Re-export utilities @@ -105,7 +105,11 @@ export async function startAnnotateServer( const configuredPort = getServerPort(); const wslFlag = await isWSL(); const gitUser = detectGitUser(); - const draftKey = contentHash(markdown); + const draftSource = + mode === "annotate-folder" && folderPath + ? `folder:${resolvePath(folderPath)}` + : markdown; + const draftKey = contentHash(draftSource); const externalAnnotations = createExternalAnnotationHandler("plan"); // Detect repo info (cached for this session) diff --git a/tests/manual/local/sandbox-opencode.sh b/tests/manual/local/sandbox-opencode.sh index ae4c1763..cf759ea7 100755 --- a/tests/manual/local/sandbox-opencode.sh +++ b/tests/manual/local/sandbox-opencode.sh @@ -19,9 +19,10 @@ # 2. Builds the plugin (ensures latest code) # 3. Creates a temp directory with git repo # 4. Creates sample files with uncommitted changes (for /plannotator-review) -# 5. Writes workflow-specific OpenCode config -# 6. Sets up the local plugin -# 6. Launches OpenCode in the sandbox +# 5. Creates two minimal folders for reproducing folder-annotation draft collisions +# 6. Writes workflow-specific OpenCode config +# 7. Sets up the local plugin +# 8. Launches OpenCode in the sandbox # # To test: # - Plan mode behavior varies by --workflow @@ -152,6 +153,7 @@ fi # Create initial project structure mkdir -p src/{api,components,hooks,utils,types} +mkdir -p docs/folder-draft-a docs/folder-draft-b mkdir -p tests cat > package.json << 'EOF' @@ -243,6 +245,21 @@ export async function fetchApi( } EOF +# Minimal folder-annotation repro fixture +cat > docs/folder-draft-a/spec.md << 'EOF' +# Folder Draft A + +- This folder exists only to reproduce draft collisions. +- Leave a draft here, then open folder B. +EOF + +cat > docs/folder-draft-b/spec.md << 'EOF' +# Folder Draft B + +- This folder exists only to reproduce draft collisions. +- If the bug is present, it will show folder A's draft. +EOF + # Task API cat > src/api/tasks.ts << 'EOF' import { fetchApi } from './client'; @@ -1684,6 +1701,11 @@ esac if [ "$NO_GIT" = false ]; then echo " 3. Code review: Run /plannotator-review" fi +echo " 4. Folder draft repro:" +echo " /plannotator-annotate docs/folder-draft-a" +echo " Type a draft in the browser, wait a few seconds, then close the tab without sending feedback" +echo " /plannotator-annotate docs/folder-draft-b" +echo " If the bug is present, folder B will show folder A's draft" echo "" echo "Launching OpenCode..." echo "" From b55884d39f15286c7fd16c6f03dcc1c1d1577120 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 22 Apr 2026 12:31:00 -0700 Subject: [PATCH 6/6] docs: remove internal opencode spec drafts --- docs/specs/opencode-manual-test-plan.md | 466 ------------------------ docs/specs/opencode-workflow-modes.md | 395 -------------------- 2 files changed, 861 deletions(-) delete mode 100644 docs/specs/opencode-manual-test-plan.md delete mode 100644 docs/specs/opencode-workflow-modes.md diff --git a/docs/specs/opencode-manual-test-plan.md b/docs/specs/opencode-manual-test-plan.md deleted file mode 100644 index 659ba252..00000000 --- a/docs/specs/opencode-manual-test-plan.md +++ /dev/null @@ -1,466 +0,0 @@ -# OpenCode Manual Test Plan - -## Purpose - -Validate the OpenCode changes on this branch end to end: - -- workflow-gated `submit_plan` access -- prompt and tool-definition behavior across workflow modes -- manual command behavior, including folder annotation -- migration behavior for existing OpenCode users - -This plan is for local testing. Do not publish the plugin to npm for these checks. - -## Scope - -In scope: - -- `workflow: "plan-agent"` default behavior -- `workflow: "manual"` commands-only behavior -- `workflow: "all-agents"` legacy broad behavior -- plan-agent prompt injection and access control -- `submit_plan` runtime rejection for the wrong agent -- `/plannotator-annotate` support for files, folders, and URLs -- `/plannotator-last` basic behavior -- doc examples and migration snippets - -Out of scope for this pass: - -- deep browser UI QA inside the Plannotator app itself -- unrelated OpenCode plugin behavior -- approval-semantics redesign beyond current behavior - -## Test Environment - -Recommended environment: - -- local checkout of this repo on the branch under test -- local OpenCode environment -- a throwaway test project for OpenCode sessions -- browser available locally - -Use a local file/path plugin. npm publishing is not required. - -Supported local setups: - -1. Put the plugin in `.opencode/plugins/` inside the test project. -2. Point `opencode.json` at a relative or absolute local plugin path. - -## Recommended Sandbox Runs - -The local OpenCode sandbox can now exercise all three workflow modes. - -From the repo root: - -```bash -bash tests/manual/local/sandbox-opencode.sh --workflow plan-agent --keep -bash tests/manual/local/sandbox-opencode.sh --workflow manual --keep -bash tests/manual/local/sandbox-opencode.sh --workflow all-agents --keep -``` - -Optional custom planning agent run: - -```bash -bash tests/manual/local/sandbox-opencode.sh --workflow plan-agent --planning-agents planner --keep -``` - -Use `--keep` while testing so the generated sandbox directory and `opencode.json` -remain available for inspection after OpenCode exits. - -## Local Plugin Setup - -### Option A: Auto-loaded project plugin - -Create this structure in the project you will open with OpenCode: - -```text -your-test-project/ - .opencode/ - package.json - plugins/ - plannotator.ts -``` - -`.opencode/package.json`: - -```json -{ - "dependencies": { - "@opencode-ai/plugin": "*" - } -} -``` - -`plannotator.ts` can re-export the local plugin entry from this repo, or you can point straight at the repo file with Option B. - -### Option B: Local path in `opencode.json` - -Use the plugin tuple form so workflow options can be changed without editing code: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { - "workflow": "plan-agent", - "planningAgents": ["plan"] - }] - ] -} -``` - -Use the same pattern for `manual` and `all-agents` test runs. - -## Test Data - -Prepare a small test workspace with: - -- one markdown file such as `notes/plan.md` -- one HTML file such as `notes/spec.html` -- one folder containing markdown or HTML files such as `specs/` -- one folder with no markdown or HTML files for negative testing -- one simple coding task that naturally triggers OpenCode planning - -## Test Matrix - -| Area | `manual` | `plan-agent` | `all-agents` | -|---|---|---|---| -| `submit_plan` registered | No | Yes | Yes | -| `plan` can call `submit_plan` | No | Yes | Yes | -| `build` can call `submit_plan` | No | No | Yes | -| full planning prompt injected for `plan` | No | Yes | Yes | -| generic reminder injected for non-plan primary agents | No | No | Yes | -| `plan_exit` / `todowrite` rewrites active | No | Yes | Yes | -| `/plannotator-last` works | Yes | Yes | Yes | -| `/plannotator-annotate` works | Yes | Yes | Yes | - -## Test Cases - -### 1. Plugin Loads Locally - -Setup: - -- Start OpenCode in the test project with the local plugin configuration. - -Verify: - -- OpenCode starts successfully. -- The plugin loads without requiring an npm publish. -- Slash commands and tool behavior match the configured workflow. - -Expected result: - -- No startup failure caused by plugin resolution. - -### 2. Default Workflow Is `plan-agent` - -Setup: - -- Omit plugin options and load the local plugin. - -Verify: - -- OpenCode behaves as if configured with: - -```json -{ - "workflow": "plan-agent", - "planningAgents": ["plan"] -} -``` - -Expected result: - -- `submit_plan` is available to `plan`. -- `build` does not get broad access by default. - -### 3. `manual` Mode Removes Automatic Planning Integration - -Setup: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { - "workflow": "manual" - }] - ] -} -``` - -Steps: - -1. Start a session and use OpenCode plan mode. -2. Inspect the available tools for `plan` and `build`. -3. Ask the agent for a plan. -4. Run `/plannotator-last`. -5. Run `/plannotator-annotate notes/plan.md`. - -Verify: - -- `submit_plan` is not registered. -- No Plannotator planning prompt is injected. -- `plan_exit` and `todowrite` descriptions are not rewritten. -- Manual commands still work. - -Expected result: - -- OpenCode planning remains native. -- Plannotator is only used when the user manually invokes it. - -### 4. `plan-agent` Mode Scopes `submit_plan` To Planning Agents - -Setup: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { - "workflow": "plan-agent", - "planningAgents": ["plan"] - }] - ] -} -``` - -Steps: - -1. Start a session with OpenCode plan mode. -2. Trigger planning with the `plan` agent. -3. Inspect the `plan` agent tool list. -4. Inspect the `build` agent tool list. -5. Ask `plan` to produce and submit a plan. - -Verify: - -- `plan` can see and call `submit_plan`. -- `build` cannot see or use `submit_plan` in the normal tool list. -- The full Plannotator planning prompt is injected for `plan`. -- The generic reminder is not injected into unrelated primary agents. -- `plan_exit` and `todowrite` rewrites still appear. - -Expected result: - -- OpenCode plan mode remains integrated with Plannotator. -- Broad primary-agent exposure is gone by default. - -### 5. Runtime Guard Rejects Wrong-Agent Calls - -Setup: - -- Stay in `plan-agent` mode. - -Steps: - -1. Try to force a `submit_plan` call from `build` or another non-planning agent. -2. If direct invocation is possible through a prompt or tool replay path, execute it. - -Verify: - -- The call is rejected with a clear message. -- Plannotator does not open. -- The rejection points users toward `/plannotator-last`, `/plannotator-annotate`, or `workflow: "all-agents"`. - -Expected result: - -- Wrong-agent invocation fails safely even if visibility checks are bypassed. - -### 6. Custom Planning Agent Works - -Setup: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { - "workflow": "plan-agent", - "planningAgents": ["planner"] - }] - ] -} -``` - -Steps: - -1. Configure or use an OpenCode agent named `planner`. -2. Start a session that routes planning through `planner`. -3. Inspect `planner` and `build`. - -Verify: - -- `planner` gets `submit_plan`. -- `build` is denied. -- Planning prompt injection follows `planner`, not `plan`. - -Expected result: - -- Workflow gating tracks the configured planning-agent list. - -### 7. `all-agents` Preserves Legacy Broad Behavior - -Setup: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - ["/absolute/path/to/plannotator/apps/opencode-plugin/index.ts", { - "workflow": "all-agents" - }] - ] -} -``` - -Steps: - -1. Start a session. -2. Inspect `plan` and `build`. -3. Ask a non-plan primary agent to produce a plan. - -Verify: - -- `submit_plan` remains broadly available to primary agents. -- The generic plan reminder is still injected for non-plan primary agents. -- `plan_exit` and `todowrite` rewrites remain active. - -Expected result: - -- Existing users can opt back into the old model. - -### 8. `/plannotator-annotate` Supports Markdown Files - -Steps: - -1. Run `/plannotator-annotate notes/plan.md`. - -Verify: - -- The annotation UI opens for the markdown file. -- File resolution works as expected. - -Expected result: - -- Existing annotate-file behavior still works. - -### 9. `/plannotator-annotate` Supports HTML Files - -Steps: - -1. Run `/plannotator-annotate notes/spec.html`. - -Verify: - -- The file is converted and opened in the annotation UI. -- Oversized HTML files still fail with a useful error. - -Expected result: - -- HTML annotation remains intact. - -### 10. `/plannotator-annotate` Supports Folders - -Steps: - -1. Run `/plannotator-annotate specs/`. -2. Repeat with `@specs/`. - -Verify: - -- The annotation UI opens in folder mode. -- The leading `@` prefix is tolerated. -- The folder path is passed through correctly. - -Expected result: - -- Folder annotation works through the OpenCode plugin command path. - -### 11. `/plannotator-annotate` Rejects Invalid Folders - -Steps: - -1. Run `/plannotator-annotate empty-folder/` where the folder contains no markdown or HTML files. - -Verify: - -- The command fails with a clear error instead of opening the UI. - -Expected result: - -- Invalid folder input is rejected cleanly. - -### 12. `/plannotator-annotate` Supports URLs - -Steps: - -1. Run `/plannotator-annotate https://example.com`. - -Verify: - -- The page is fetched and opened in the annotation UI. - -Expected result: - -- URL annotation still works after the command changes. - -### 13. `/plannotator-last` Still Works In Every Workflow - -Steps: - -1. Have the agent produce a normal message. -2. Run `/plannotator-last`. - -Verify: - -- The last assistant message opens for annotation. -- Returned feedback is sent back into the session. - -Expected result: - -- Manual review of the latest assistant output remains available regardless of workflow mode. - -### 14. Migration Docs Match Runtime Behavior - -Files to spot-check: - -- `apps/opencode-plugin/README.md` -- `apps/marketing/src/content/docs/guides/opencode.md` -- `apps/marketing/src/content/docs/getting-started/configuration.md` -- `apps/marketing/src/content/docs/getting-started/installation.md` -- `apps/marketing/src/content/docs/guides/troubleshooting.md` - -Verify: - -- Docs say the default is `plan-agent`. -- Docs show how to opt into `all-agents`. -- Docs show how to opt into `manual`. -- Docs say `/plannotator-annotate` supports folders. -- Troubleshooting explains why `build` cannot call `submit_plan` by default. - -Expected result: - -- Documentation matches actual plugin behavior. - -## Regression Checks - -Before signoff, confirm: - -- `submit_plan` still opens the browser UI and completes a normal review cycle. -- approved plans still return success to the agent -- denied plans still return revision feedback -- plan-agent mode does not break OpenCode plan mode -- manual mode does not accidentally register `submit_plan` -- all-agents mode does not accidentally deny `build` - -## Signoff Criteria - -This change is ready if: - -- all three workflow modes behave as documented -- default behavior is `plan-agent` -- non-planning agents no longer get eager `submit_plan` exposure by default -- manual commands remain strong first-class paths -- folder annotation works through the OpenCode plugin -- migration docs are accurate diff --git a/docs/specs/opencode-workflow-modes.md b/docs/specs/opencode-workflow-modes.md deleted file mode 100644 index 657729e9..00000000 --- a/docs/specs/opencode-workflow-modes.md +++ /dev/null @@ -1,395 +0,0 @@ -# OpenCode Workflow Modes - -## Status - -Draft proposal for issue [#479](https://github.com/backnotprop/plannotator/issues/479). - -## Context - -Plannotator's OpenCode plugin currently exposes `submit_plan` broadly to primary -agents and nudges agents toward using it for plan review. This works well for -users who want Plannotator to own the plan approval loop, but recent feedback -shows three distinct workflows: - -1. Users who want Plannotator to integrate with OpenCode plan mode. -2. Users who want manual review through `/plannotator-last` and - `/plannotator-annotate`. -3. Users who want the legacy behavior where any primary agent can submit plans. - -The current implementation blurs those workflows. In particular, non-plan -primary agents can still see `submit_plan`, and some users experience the tool -as eager or intrusive during small plans, OpenSpec-style artifact planning, or -normal OpenCode flow. - -This spec makes those workflows explicit. - -## Goals - -- Keep OpenCode plan-mode integration as a first-class feature. -- Make `/plannotator-last` and `/plannotator-annotate` first-class manual - features, not fallback paths. -- Stop broad primary-agent exposure by default. -- Preserve the current broad behavior as an explicit compatibility mode. -- Support users who want Plannotator to gate plans created by configured - planning agents. -- Support users who want native OpenCode planning plus manual Plannotator - review through commands. - -## Non-Goals - -- Remove OpenCode plan-mode integration. -- Replace OpenCode's native plan mode. -- Make browser UI settings the source of truth for tool registration. -- Add a new `/plannotator-last-plan` command as part of the first phase. - -`/plannotator-last-plan` may still be useful later, but current Pi and OpenCode -feedback suggests `/plannotator-last` and `/plannotator-annotate` already cover -the most valuable manual entry points. - -## User-Facing Modes - -### `manual` - -Manual review mode. - -Behavior: - -- Do not register `submit_plan`. -- Do not inject Plannotator plan-submission prompts. -- Do not rewrite `plan_exit` or `todowrite`. -- Keep manual commands available: - - `/plannotator-last` - - `/plannotator-annotate` - - `/plannotator-review` - - `/plannotator-archive` -- Let OpenCode planning behave natively. - -This does not mean Plannotator is not part of planning. It means Plannotator -does not automatically interrupt planning. Users can still run -`/plannotator-last` on a plan message or `/plannotator-annotate` on a spec, -plan file, directory, or URL. - -### `plan-agent` - -Scoped automatic plan review mode. This should be the first migration default. - -Behavior: - -- Register `submit_plan`. -- Expose `submit_plan` only to configured planning agents. -- Hide or deny `submit_plan` for non-planning agents using OpenCode agent - permissions where possible. -- Also reject calls in `submit_plan.execute()` if `context.agent` is not in the - configured planning-agent list. -- Inject Plannotator planning guidance only for configured planning agents. -- Do not inject the lightweight "Plan Submission" reminder into arbitrary - primary agents. - -This mode is for users who want Plannotator integrated with OpenCode plan mode, -without letting `build` or other implementation agents call `submit_plan`. - -### `all-agents` - -Legacy broad automatic mode. - -Behavior: - -- Preserve today's broad behavior as much as practical. -- Register `submit_plan`. -- Allow primary agents to call `submit_plan`. -- Keep subagent behavior governed by the existing `primary_tools`/subagent - hiding mechanism unless explicitly overridden. - -This mode exists for users who intentionally rely on the current broad access -model. - -## Proposed Config - -OpenCode plugin-specific config should live in plugin tuple options, not a -top-level `plannotator` key. - -```json -{ - "plugin": [ - ["@plannotator/opencode@latest", { - "workflow": "plan-agent", - "planningAgents": ["plan"] - }] - ] -} -``` - -Fields: - -- `workflow`: `"manual" | "plan-agent" | "all-agents"` -- `planningAgents`: string array, default `["plan"]` - -Recommended defaults: - -```json -{ - "workflow": "plan-agent", - "planningAgents": ["plan"] -} -``` - -Environment variables may exist as temporary migration aids, but plugin tuple -options should be the durable interface. - -## OpenCode Source Findings - -External OpenCode source review answered the main integration questions: - -- Plugins can conditionally register tools at startup. If the plugin does not - return `tool.submit_plan`, the model does not see that tool. -- Plugin tool definitions do not have a native `visibleTo` field. -- OpenCode does filter tools through active agent permissions, and plugin tool - names can be controlled through the permission catchall. This allows - `agent..permission.submit_plan = "deny"` for non-planning agents. -- Plugin tool execution receives the invoking agent as `context.agent`. -- `client.session.prompt({ agent })` selects the agent for that specific prompt; - it does not generally mutate the TUI's selected agent. -- There is no plugin-native "approve but do not continue" primitive. To approve - without continuing, do not call `session.prompt()`. -- Top-level OpenCode config is strict, so plugin-specific settings belong in - plugin tuple options. - -## Implementation Design - -### Options Parsing - -Add an options schema near the OpenCode plugin entry: - -```ts -type WorkflowMode = "manual" | "plan-agent" | "all-agents"; - -interface PlannotatorOpenCodeOptions { - workflow?: WorkflowMode; - planningAgents?: string[]; -} -``` - -Normalize options once at plugin startup: - -- invalid or missing `workflow` -> `plan-agent` -- empty or missing `planningAgents` -> `["plan"]` -- agent names should be trimmed and deduplicated - -### Tool Registration - -Build the plugin return object conditionally: - -- `manual`: omit `tool.submit_plan` -- `plan-agent`: include `tool.submit_plan` -- `all-agents`: include `tool.submit_plan` - -This keeps OpenCode plan-mode integration working by default while removing the -broad non-plan-agent exposure that causes eager calls from implementation -agents. - -### Config Hook - -Mode-specific behavior: - -- `manual` - - do not add `submit_plan` to `experimental.primary_tools` - - do not mutate `agent.plan.permission.edit` - - do not add `submit_plan` permissions - -- `plan-agent` - - add `submit_plan` to `experimental.primary_tools` to keep it hidden from - subagents by default - - allow markdown editing for configured planning agents if needed - - deny `submit_plan` for known non-planning primary agents where OpenCode - agent config is available - -- `all-agents` - - preserve current primary-agent behavior - - keep `experimental.primary_tools` subagent hiding unless - `PLANNOTATOR_ALLOW_SUBAGENTS` is enabled - -### Prompt Hooks - -Mode-specific behavior: - -- `manual` - - no Plannotator prompt injection - - no `STRICTLY FORBIDDEN` replacement - - no `plan_exit` or `todowrite` description rewrites - -- `plan-agent` - - inject only for configured planning agents - - do not inject the lightweight reminder into other primary agents - - keep the `plan_exit` and `todowrite` tool-definition rewrites as mild - global compatibility adjustments because OpenCode's `tool.definition` hook - does not expose active-agent context - -- `all-agents` - - preserve current behavior, with any obvious bugs fixed - -The `plan_exit` and `todowrite` rewrites are not access control. `submit_plan` -visibility is controlled through OpenCode permission mutation where possible, -and correctness is enforced in `submit_plan.execute()` via `context.agent`. - -### Runtime Guard - -In `submit_plan.execute()`: - -- If workflow is `plan-agent` and `context.agent` is not in `planningAgents`, - return a clear rejection message instead of opening Plannotator. -- This guard is required even if permissions hide the tool, because permissions - are a visibility mechanism and runtime enforcement should still be explicit. - -Suggested message: - -```text -Plannotator is configured for plan-agent mode. submit_plan can only be called by: -plan - -Use /plannotator-last or /plannotator-annotate for manual review. -``` - -### Approval Handoff - -Approval should be decoupled from automatic implementation. - -Existing behavior sends `session.prompt()` when agent switching is enabled. For -future migration stages: - -- `manual`: not applicable because `submit_plan` is not registered -- `plan-agent`: default should be stay/stop; do not call `session.prompt()` - unless the user explicitly configured a continuation target -- `all-agents`: preserve existing behavior for compatibility - -The default agent-switch setting should be revisited separately. The current -fallback to `build` is still too opinionated for many OpenCode workflows, but -it is not part of the first migration stage. - -## Manual Features - -The following commands should be documented as first-class OpenCode workflows: - -### `/plannotator-last` - -Review or annotate the most recent assistant response. Useful when an OpenCode -agent produced a plan, explanation, design, or answer that the user wants to -review manually. - -### `/plannotator-annotate` - -Review arbitrary artifacts: - -- markdown files -- directories -- URLs -- specs or plan documents produced by tools such as OpenSpec - -This command is especially important for users whose planning process is -artifact-driven instead of chat-plan-driven. - -## Migration - -The first migration should narrow the default from broad primary-agent exposure -to plan-agent-only exposure. This keeps Plannotator integrated with OpenCode -plan mode while stopping `build` and other non-plan primary agents from seeing -or being nudged toward `submit_plan`. - -Default behavior with omitted config: - -```json -{ - "workflow": "plan-agent", - "planningAgents": ["plan"] -} -``` - -Existing users who want the current broad behavior should opt in: - -```json -{ - "plugin": [ - ["@plannotator/opencode@latest", { - "workflow": "all-agents" - }] - ] -} -``` - -Users who want automatic review only from the plan agent: - -```json -{ - "plugin": [ - ["@plannotator/opencode@latest", { - "workflow": "plan-agent", - "planningAgents": ["plan"] - }] - ] -} -``` - -Users who want native OpenCode planning plus manual Plannotator review: - -```json -{ - "plugin": [ - ["@plannotator/opencode@latest", { - "workflow": "manual" - }] - ] -} -``` - -## Phased Rollout - -### Stage 1: Narrow Default To `plan-agent` - -Goal: stop broad primary-agent exposure without removing OpenCode plan -integration. - -- Add plugin option parsing. -- Default to `plan-agent`. -- Keep `submit_plan` registered for automatic workflows. -- Omit `submit_plan` only in `manual`. -- Remove the generic `submit_plan` reminder for non-plan primary agents in the - default mode. -- Inject Plannotator planning guidance only for configured planning agents. -- Patch OpenCode permissions: - - `build.permission.submit_plan = "deny"` - - configured planning agents get `submit_plan = "allow"` - - user-configured non-planning primary agents get `submit_plan = "deny"` -- Add a runtime guard using `context.agent`. -- Keep the `plan_exit` and `todowrite` rewrites for `plan-agent` and - `all-agents`. -- Preserve current behavior under `all-agents`. -- Support `manual` as commands-only mode. - -### Stage 2: Documentation And Migration UX - -Goal: make the behavior change understandable. - -- Update OpenCode README and website docs. -- Document all three modes. -- Add migration snippets: - - old behavior: `workflow: "all-agents"` - - default plan-agent behavior: `workflow: "plan-agent"` - - commands-only: `workflow: "manual"` -- Update troubleshooting around why `build` cannot call `submit_plan` by - default. - -### Stage 3: Approval Semantics - -- Revisit default approval behavior. -- Make stay/stop the default for `plan-agent`. -- Preserve current implementation handoff under `all-agents`. -- Update UI copy to make continuation behavior explicit. - -### Stage 4: Optional Manual Plan Command - -Only if users ask for plan-specific manual semantics: - -- Add `/plannotator-last-plan`. -- Prefer latest assistant message from configured planning agents. -- Open plan-review UI instead of annotate-last mode. - -This should not block the first three phases.