mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
d5b6bc702b
* fix: restore uniform submit_plan and add submit_plan_file for OpenCode PR #318 replaced the original text-based submit_plan with a file-path-only version and split prompt injection between plan/non-plan agents. This caused regressions: the agent couldn't figure out file paths from non-plan agents, and the 80+ line prompt with TodoWrite replacements was fragile against OpenCode upstream changes. This restores the original submit_plan(plan) that accepts markdown text directly — the contract that worked uniformly across all agents — and keeps the file-based workflow as submit_plan_file(path) for users who want persistent plan files. Key changes: - Two tools: submit_plan (text) + submit_plan_file (path) - Unified prompt for all primary agents (not just plan mode) - Removed aggressive TodoWrite string replacements and system-reminder - Kept adversarial stripping of OpenCode's STRICTLY FORBIDDEN rules - Extracted shared server helpers to reduce duplication Addresses #328 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: single submit_plan with auto-detect text/path for OpenCode Collapses submit_plan + submit_plan_file back into one tool that auto-detects whether the argument is plan text or a file path. First submission: agent passes markdown text (simple, works from any agent). On deny: response includes the history path where the plan was saved, so the agent can Edit the file for targeted revisions and resubmit with the path. Key changes: - One tool: submit_plan(plan) accepts text or absolute .md file path - Server surfaces historyPath through waitForDecision (already saved by saveToHistory, just not returned before) - Deny response includes file path hint for Edit-based revision workflow - Unified prompt for all primary agents (~25 lines, no TodoWrite warfare) - Still strips OpenCode's STRICTLY FORBIDDEN rules and suppresses plan_exit Addresses #328 and rcdailey's feedback on PR #333 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove historyPath leak, error on missing file paths, restore two-tier prompt - Remove historyPath from deny flow — internal history directory should not be exposed to agents. Text submissions get text feedback; file submissions get file feedback. No crossover. - Error when agent passes an absolute .md path that doesn't exist instead of silently treating it as plan text. - Restore two-tier prompt: plan agent gets full planning instructions, other primary agents get a minimal reminder (matching pre-v0.13.0 behavior). - Clean up stale prompt copy referencing historyPath-based revision flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore todowrite override, strengthen planning prompt, remove noisy logs - Override todowrite description to defer to submit_plan during active planning - Tool description now instructs agent to explore and ask questions before submitting - Sequenced planning prompt: explore, ask, then write - Remove success logs from integration saves (stderr was bleeding through) - Append resubmit reminder to OpenCode deny feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
/**
|
|
* Plannotator Plugin for OpenCode
|
|
*
|
|
* Provides interactive browser-based plan review via a single tool:
|
|
* submit_plan(plan) — accepts either markdown text or a file path
|
|
*
|
|
* First submission: agent passes plan as text. On deny, the response includes
|
|
* the path where the plan was saved, enabling the agent to use Edit for targeted
|
|
* revisions and resubmit with the file path.
|
|
*
|
|
* Environment variables:
|
|
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (devcontainer, SSH)
|
|
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
|
* PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for approval (default: 345600, set 0 to disable)
|
|
* PLANNOTATOR_ALLOW_SUBAGENTS - Set to "1" to allow subagents to see submit_plan
|
|
*
|
|
* @packageDocumentation
|
|
*/
|
|
|
|
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
import { existsSync, readFileSync } from "fs";
|
|
import path from "path";
|
|
import {
|
|
startPlannotatorServer,
|
|
handleServerReady,
|
|
} from "@plannotator/server";
|
|
import {
|
|
startReviewServer,
|
|
handleReviewServerReady,
|
|
} from "@plannotator/server/review";
|
|
import {
|
|
startAnnotateServer,
|
|
handleAnnotateServerReady,
|
|
} from "@plannotator/server/annotate";
|
|
import { writeRemoteShareLink } from "@plannotator/server/share-url";
|
|
import {
|
|
handleReviewCommand,
|
|
handleAnnotateCommand,
|
|
handleAnnotateLastCommand,
|
|
type CommandDeps,
|
|
} from "./commands";
|
|
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
|
|
import {
|
|
stripConflictingPlanModeRules,
|
|
} from "./plan-mode";
|
|
|
|
// @ts-ignore - Bun import attribute for text
|
|
import indexHtml from "./plannotator.html" with { type: "text" };
|
|
const htmlContent = indexHtml as unknown as string;
|
|
|
|
// @ts-ignore - Bun import attribute for text
|
|
import reviewHtml from "./review-editor.html" with { type: "text" };
|
|
const reviewHtmlContent = reviewHtml as unknown as string;
|
|
|
|
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours
|
|
|
|
// ── Auto-detection ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Detect whether the submit_plan argument is a file path.
|
|
* Must be an absolute path, end in .md, and exist on disk.
|
|
* Anything that doesn't match is treated as plan text.
|
|
*/
|
|
function isFilePath(value: string): boolean {
|
|
return path.isAbsolute(value) && value.endsWith(".md") && existsSync(value);
|
|
}
|
|
|
|
/**
|
|
* Resolve the plan content from the submit_plan argument.
|
|
* Returns the markdown text and optionally the source file path.
|
|
*/
|
|
function resolvePlanContent(plan: string): { content: string; filePath?: string } {
|
|
if (isFilePath(plan)) {
|
|
const content = readFileSync(plan, "utf-8");
|
|
if (!content.trim()) {
|
|
throw new Error(`Plan file at ${plan} is empty. Write your plan content first, then call submit_plan.`);
|
|
}
|
|
return { content, filePath: plan };
|
|
}
|
|
// Catch typos: looks like a file path but doesn't exist
|
|
if (path.isAbsolute(plan) && plan.endsWith(".md")) {
|
|
throw new Error(`File not found: ${plan}. Check the path and try again.`);
|
|
}
|
|
return { content: plan };
|
|
}
|
|
|
|
// ── Planning prompt ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Unified planning prompt injected for all primary agents.
|
|
*
|
|
* Design principles:
|
|
* - Explain the WHY — the model is smart, give it context
|
|
* - Keep it lean — every line should pull its weight
|
|
* - Don't overfit — let the agent and user dictate the workflow
|
|
* - One tool, two modes — text for first submission, file path for revisions
|
|
*/
|
|
function getPlanningPrompt(): string {
|
|
return `## Plannotator — Plan Review
|
|
|
|
You have a plan submission tool called \`submit_plan\`. It opens an interactive review UI where the user can annotate, approve, or request changes.
|
|
|
|
**How to use it:**
|
|
|
|
- Pass your plan as markdown text — \`submit_plan(plan: "# My Plan\\n...")\`.
|
|
- Or pass an absolute file path to a .md file — \`submit_plan(plan: "/path/to/plan.md")\`.
|
|
|
|
The tool auto-detects whether you passed text or a file path. Both open the same review UI.
|
|
|
|
### Before you write a plan
|
|
|
|
Do not jump straight to writing a plan. First:
|
|
|
|
1. **Explore** — Read the relevant code, trace dependencies, and look at existing patterns. The depth should match the task.
|
|
2. **Ask questions** — If you need information only the user can provide (requirements, preferences, tradeoffs), ask using the \`question\` tool. Don't guess at ambiguous requirements.
|
|
|
|
Only write and submit a plan once you have sufficient context.
|
|
|
|
### What NOT to do
|
|
|
|
- Don't proceed with implementation until the plan is approved.
|
|
- Don't use \`plan_exit\` — use \`submit_plan\` instead.
|
|
- Don't end your turn without either submitting a plan or asking the user a question.`;
|
|
}
|
|
|
|
// ── Plugin ────────────────────────────────────────────────────────────────
|
|
|
|
export const PlannotatorPlugin: Plugin = async (ctx) => {
|
|
let cachedAgents: any[] | null = null;
|
|
|
|
async function getSharingEnabled(): Promise<boolean> {
|
|
try {
|
|
const response = await ctx.client.config.get({ query: { directory: ctx.directory } });
|
|
// @ts-ignore - share config may exist
|
|
const share = response?.data?.share;
|
|
if (share !== undefined) {
|
|
return share !== "disabled";
|
|
}
|
|
} catch {
|
|
// Config read failed, fall through to env var
|
|
}
|
|
return process.env.PLANNOTATOR_SHARE !== "disabled";
|
|
}
|
|
|
|
function getShareBaseUrl(): string | undefined {
|
|
return process.env.PLANNOTATOR_SHARE_URL || undefined;
|
|
}
|
|
|
|
function getPlanTimeoutSeconds(): number | null {
|
|
const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
|
|
if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
|
|
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
console.error(
|
|
`[Plannotator] Invalid PLANNOTATOR_PLAN_TIMEOUT_SECONDS="${raw}". Using default ${DEFAULT_PLAN_TIMEOUT_SECONDS}s.`
|
|
);
|
|
return DEFAULT_PLAN_TIMEOUT_SECONDS;
|
|
}
|
|
|
|
if (parsed === 0) return null;
|
|
return parsed;
|
|
}
|
|
|
|
function allowSubagents(): boolean {
|
|
const val = process.env.PLANNOTATOR_ALLOW_SUBAGENTS?.trim();
|
|
return val === "1" || val === "true";
|
|
}
|
|
|
|
return {
|
|
// Register submit_plan as primary-only tool (hidden from sub-agents by default)
|
|
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 = {
|
|
...opencodeConfig.agent.plan.permission.edit,
|
|
"*.md": "allow",
|
|
};
|
|
},
|
|
|
|
// Strip OpenCode's "STRICTLY FORBIDDEN" plan mode prompt from synthetic
|
|
// user messages. OpenCode injects these to prevent file edits in plan mode,
|
|
// but we need the agent to be able to write plan files.
|
|
"experimental.chat.messages.transform": async (input, output) => {
|
|
for (const message of output.messages) {
|
|
if (message.info.role !== "user") continue;
|
|
message.parts = message.parts.filter(
|
|
(part: any) => !(part.type === "text" && part.text?.includes("STRICTLY FORBIDDEN"))
|
|
);
|
|
}
|
|
},
|
|
|
|
// Suppress plan_exit — redirect to submit_plan
|
|
// Override todowrite — defer to submit_plan during planning
|
|
"tool.definition": async (input, output) => {
|
|
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.";
|
|
}
|
|
if (input.toolID === "todowrite") {
|
|
output.description =
|
|
"While actively planning with the user, use submit_plan instead. Only use todos once implementation begins or unless the user explicitly asks.";
|
|
}
|
|
},
|
|
|
|
// Inject planning instructions into system prompt
|
|
"experimental.chat.system.transform": async (input, output) => {
|
|
const systemText = output.system.join("\n");
|
|
if (systemText.toLowerCase().includes("title generator") || systemText.toLowerCase().includes("generate a title")) {
|
|
return;
|
|
}
|
|
|
|
let lastUserAgent: string | undefined;
|
|
try {
|
|
const messagesResponse = await ctx.client.session.messages({
|
|
// @ts-ignore - sessionID exists on input
|
|
path: { id: input.sessionID }
|
|
});
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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({
|
|
query: { directory: ctx.directory }
|
|
});
|
|
cachedAgents = agentsResponse.data ?? [];
|
|
}
|
|
const agent = cachedAgents.find((a: { name: string }) => a.name === lastUserAgent);
|
|
|
|
// Skip sub-agents
|
|
// @ts-ignore - Agent has mode field
|
|
if (agent?.mode === "subagent") return;
|
|
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
// Plan agent: strip conflicting OpenCode rules, inject full prompt
|
|
if (lastUserAgent === "plan") {
|
|
output.system = stripConflictingPlanModeRules(output.system);
|
|
output.system.push(getPlanningPrompt());
|
|
return;
|
|
}
|
|
|
|
// Other primary agents: minimal reminder about the tool
|
|
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.
|
|
|
|
The user will review your plan in a visual UI where they can annotate, approve, or request changes. If rejected, revise based on their feedback and call submit_plan again.
|
|
|
|
Do NOT proceed with implementation until your plan is approved.`);
|
|
},
|
|
|
|
// Intercept plannotator-last before the agent sees the command
|
|
"command.execute.before": async (input, output) => {
|
|
if (input.command !== "plannotator-last") return;
|
|
|
|
output.parts = [];
|
|
|
|
const deps: CommandDeps = {
|
|
client: ctx.client,
|
|
htmlContent,
|
|
reviewHtmlContent,
|
|
getSharingEnabled,
|
|
getShareBaseUrl,
|
|
directory: ctx.directory,
|
|
};
|
|
|
|
const feedback = await handleAnnotateLastCommand(
|
|
{ properties: { sessionID: input.sessionID } },
|
|
deps
|
|
);
|
|
|
|
if (feedback) {
|
|
try {
|
|
await ctx.client.session.prompt({
|
|
path: { id: input.sessionID },
|
|
body: {
|
|
parts: [{
|
|
type: "text",
|
|
text: `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`,
|
|
}],
|
|
},
|
|
});
|
|
} catch {
|
|
// Session may not be available
|
|
}
|
|
}
|
|
},
|
|
|
|
// Listen for slash commands (review + annotate)
|
|
event: async ({ event }) => {
|
|
const isCommandEvent =
|
|
event.type === "command.executed" ||
|
|
event.type === "tui.command.execute";
|
|
|
|
if (!isCommandEvent) return;
|
|
|
|
// @ts-ignore - Event structure varies
|
|
const commandName = event.properties?.name || event.command || event.payload?.name;
|
|
|
|
const deps: CommandDeps = {
|
|
client: ctx.client,
|
|
htmlContent,
|
|
reviewHtmlContent,
|
|
getSharingEnabled,
|
|
getShareBaseUrl,
|
|
directory: ctx.directory,
|
|
};
|
|
|
|
if (commandName === "plannotator-review")
|
|
return handleReviewCommand(event, deps);
|
|
if (commandName === "plannotator-annotate")
|
|
return handleAnnotateCommand(event, deps);
|
|
},
|
|
|
|
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.",
|
|
args: {
|
|
plan: tool.schema
|
|
.string()
|
|
.describe("The plan — either markdown text or an absolute path to a .md file on disk."),
|
|
},
|
|
|
|
async execute(args, context) {
|
|
// Auto-detect: file path or plan text
|
|
let planContent: string;
|
|
let sourceFilePath: string | undefined;
|
|
try {
|
|
const resolved = resolvePlanContent(args.plan);
|
|
planContent = resolved.content;
|
|
sourceFilePath = resolved.filePath;
|
|
} catch (err) {
|
|
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
}
|
|
|
|
if (!planContent.trim()) {
|
|
return "Error: Plan content is empty. Write your plan first, then call submit_plan.";
|
|
}
|
|
|
|
const sharingEnabled = await getSharingEnabled();
|
|
const server = await startPlannotatorServer({
|
|
plan: planContent,
|
|
origin: "opencode",
|
|
sharingEnabled,
|
|
shareBaseUrl: getShareBaseUrl(),
|
|
htmlContent,
|
|
opencodeClient: ctx.client,
|
|
onReady: async (url, isRemote, port) => {
|
|
handleServerReady(url, isRemote, port);
|
|
if (isRemote && sharingEnabled) {
|
|
await writeRemoteShareLink(planContent, getShareBaseUrl(), "review the plan", "plan only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
const timeoutSeconds = getPlanTimeoutSeconds();
|
|
const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000;
|
|
|
|
const result = timeoutMs === null
|
|
? await server.waitForDecision()
|
|
: await new Promise<Awaited<ReturnType<typeof server.waitForDecision>>>((resolve) => {
|
|
const timeoutId = setTimeout(
|
|
() =>
|
|
resolve({
|
|
approved: false,
|
|
feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`,
|
|
}),
|
|
timeoutMs
|
|
);
|
|
|
|
server.waitForDecision().then((r) => {
|
|
clearTimeout(timeoutId);
|
|
resolve(r);
|
|
});
|
|
});
|
|
await Bun.sleep(1500);
|
|
server.stop();
|
|
|
|
if (result.approved) {
|
|
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled';
|
|
const targetAgent = result.agentSwitch || 'build';
|
|
|
|
if (shouldSwitchAgent) {
|
|
try {
|
|
await ctx.client.tui.executeCommand({
|
|
body: { command: "agent_cycle" },
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
|
|
try {
|
|
await ctx.client.session.prompt({
|
|
path: { id: context.sessionID },
|
|
body: {
|
|
agent: targetAgent,
|
|
noReply: true,
|
|
parts: [{ type: "text", text: "Proceed with implementation" }],
|
|
},
|
|
});
|
|
} catch {
|
|
// Silently fail if session is busy
|
|
}
|
|
}
|
|
|
|
if (result.feedback) {
|
|
return `Plan approved with notes!
|
|
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}
|
|
|
|
## Implementation Notes
|
|
|
|
The user approved your plan but added the following notes to consider during implementation:
|
|
|
|
${result.feedback}
|
|
|
|
Proceed with implementation, incorporating these notes where applicable.`;
|
|
}
|
|
|
|
return `Plan approved!${result.savedPath ? ` Saved to: ${result.savedPath}` : ""}`;
|
|
} else {
|
|
return planDenyFeedback(result.feedback || "", "submit_plan", {
|
|
planFilePath: sourceFilePath,
|
|
}) + "\n\nAfter making your revisions, call `submit_plan` again to resubmit for review.";
|
|
}
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
};
|
|
|
|
export default PlannotatorPlugin;
|