diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 1cba685f..220e5b25 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -1,17 +1,15 @@ /** * Plannotator Pi Extension — File-based plan mode with visual browser review. * - * Plans are written to PLAN.md on disk (git-trackable, editor-visible). - * The agent calls plannotator_submit_plan to request approval; the user - * reviews the plan in the Plannotator browser UI and can approve, deny - * with annotations, or request changes. + * During planning the agent writes any markdown file anywhere inside cwd and + * calls plannotator_submit_plan with the path. The user reviews in the + * browser UI and can approve, deny with annotations, or request changes. * * Features: * - /plannotator command or Ctrl+Alt+P to toggle * - --plan flag to start in planning mode - * - --plan-file flag to customize the plan file path * - Bash unrestricted during planning (prompt-guided) - * - Write restricted to plan file only during planning + * - Writes restricted to markdown files inside cwd during planning * - plannotator_submit_plan tool with browser-based visual approval * - [DONE:n] markers for execution progress tracking * - /plannotator-review command for code review @@ -53,6 +51,7 @@ import { } from "./plannotator-events.js"; import { getToolsForPhase, + isPlanWritePathAllowed, PLAN_SUBMIT_TOOL, type Phase, stripPlanningOnlyTools, @@ -69,7 +68,7 @@ type SavedPhaseState = { type PersistedPlannotatorState = { phase: Phase; - planFilePath?: string; + lastSubmittedPath?: string; savedState?: SavedPhaseState; }; @@ -106,7 +105,7 @@ function getPlanReviewAvailabilityWarning(options: { hasUI: boolean; hasPlanHtml export default function plannotator(pi: ExtensionAPI): void { let phase: Phase = "idle"; void registerPlannotatorEventListeners(pi); - let planFilePath = "PLAN.md"; + let lastSubmittedPath: string | null = null; let checklistItems: ChecklistItem[] = []; let savedState: SavedPhaseState | null = null; let plannotatorConfig = {}; @@ -119,18 +118,8 @@ export default function plannotator(pi: ExtensionAPI): void { default: false, }); - pi.registerFlag("plan-file", { - description: "Plan file path (default: PLAN.md)", - type: "string", - default: "PLAN.md", - }); - // ── Helpers ────────────────────────────────────────────────────────── - function resolvePlanPath(cwd: string): string { - return resolve(cwd, planFilePath); - } - function getPhaseProfile(): ReturnType | undefined { if (phase === "planning" || phase === "executing") { return resolvePhaseProfile(plannotatorConfig, phase); @@ -183,7 +172,7 @@ export default function plannotator(pi: ExtensionAPI): void { function persistState(): void { - pi.appendEntry("plannotator", { phase, planFilePath, savedState }); + pi.appendEntry("plannotator", { phase, lastSubmittedPath, savedState }); } async function applyModelRef( @@ -249,7 +238,7 @@ export default function plannotator(pi: ExtensionAPI): void { await applyPhaseConfig(ctx, { restoreSavedState: false }); persistState(); ctx.ui.notify( - `Plannotator: planning mode enabled. Write your plan to ${planFilePath}.`, + "Plannotator: planning mode enabled. Write a markdown plan, then submit it for review.", ); const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() }); if (warning) { @@ -260,7 +249,7 @@ export default function plannotator(pi: ExtensionAPI): void { async function exitToIdle(ctx: ExtensionContext): Promise { phase = "idle"; checklistItems = []; - + lastSubmittedPath = null; await restoreSavedState(ctx); savedState = null; @@ -281,31 +270,19 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Commands & Shortcuts ───────────────────────────────────────────── pi.registerCommand("plannotator", { - description: "Toggle plannotator (file-based plan mode)", - handler: async (args, ctx) => { - if (phase !== "idle") { - await exitToIdle(ctx); - return; - } - - // Accept path as argument: /plannotator plans/auth.md - let targetPath = args?.trim() || undefined; - - // No arg — prompt for file path interactively - if (!targetPath && ctx.hasUI) { - targetPath = await ctx.ui.input("Plan file path", planFilePath); - if (targetPath === undefined) return; // cancelled - } - - if (targetPath) planFilePath = targetPath; - await enterPlanning(ctx); + description: "Toggle plannotator planning mode", + handler: async (_args, ctx) => { + await togglePlanMode(ctx); }, }); pi.registerCommand("plannotator-status", { description: "Show plannotator status", handler: async (_args, ctx) => { - const parts = [`Phase: ${phase}`, `Plan file: ${planFilePath}`]; + const parts = [`Phase: ${phase}`]; + if (lastSubmittedPath) { + parts.push(`Plan file: ${lastSubmittedPath}`); + } if (checklistItems.length > 0) { const done = checklistItems.filter((t) => t.completed).length; parts.push(`Progress: ${done}/${checklistItems.length}`); @@ -314,27 +291,6 @@ export default function plannotator(pi: ExtensionAPI): void { }, }); - pi.registerCommand("plannotator-set-file", { - description: "Change the plan file path", - handler: async (args, ctx) => { - let targetPath = args?.trim() || undefined; - - if (!targetPath && ctx.hasUI) { - targetPath = await ctx.ui.input("Plan file path", planFilePath); - if (targetPath === undefined) return; // cancelled - } - - if (!targetPath) { - ctx.ui.notify(`Current plan file: ${planFilePath}`, "info"); - return; - } - - planFilePath = targetPath; - persistState(); - ctx.ui.notify(`Plan file changed to: ${planFilePath}`); - }, - }); - pi.registerCommand("plannotator-review", { description: "Open interactive code review for current changes or a PR URL", handler: async (args, ctx) => { @@ -557,18 +513,18 @@ export default function plannotator(pi: ExtensionAPI): void { label: "Submit Plan", description: "Submit your Plannotator plan for user review. " + - "Call this only while Plannotator planning mode is active, after drafting or revising your plan file. " + + "Call this only while Plannotator planning mode is active, after writing your plan as a markdown file anywhere inside the working directory. " + + "Pass the path to the plan file (e.g. PLAN.md or plans/auth.md). " + "The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " + - "If denied, use the edit tool to make targeted revisions (not write), then call this again.", + "If denied, edit the same file in place, then call this again with the same path.", parameters: Type.Object({ - summary: Type.Optional( - Type.String({ - description: "Brief summary of the plan for the user's review", - }), - ), + filePath: Type.String({ + description: + "Path to the markdown plan file, relative to the working directory. Must end in .md or .mdx and resolve inside cwd.", + }), }) as any, - async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { // Guard: must be in planning phase if (phase !== "planning") { return { @@ -582,17 +538,66 @@ export default function plannotator(pi: ExtensionAPI): void { }; } - // Read plan file - const fullPath = resolvePlanPath(ctx.cwd); + const inputPath = (params as { filePath?: string })?.filePath?.trim(); + if (!inputPath) { + return { + content: [ + { + type: "text", + text: `Error: ${PLAN_SUBMIT_TOOL} requires a filePath argument pointing to your markdown plan file (e.g. "PLAN.md" or "plans/auth.md").`, + }, + ], + details: { approved: false }, + }; + } + + if (!isPlanWritePathAllowed(inputPath, ctx.cwd)) { + return { + content: [ + { + type: "text", + text: `Error: plan file must be a markdown file (.md or .mdx) inside the working directory. Rejected: ${inputPath}`, + }, + ], + details: { approved: false }, + }; + } + + const fullPath = resolve(ctx.cwd, inputPath); + + try { + if (!statSync(fullPath).isFile()) { + return { + content: [ + { + type: "text", + text: `Error: ${inputPath} is not a regular file. Write your plan to a markdown file first, then call ${PLAN_SUBMIT_TOOL} with its path.`, + }, + ], + details: { approved: false }, + }; + } + } catch { + return { + content: [ + { + type: "text", + text: `Error: ${inputPath} does not exist. Write your plan using the write tool first, then call ${PLAN_SUBMIT_TOOL} again.`, + }, + ], + details: { approved: false }, + }; + } + let planContent: string; try { planContent = readFileSync(fullPath, "utf-8"); - } catch { + } catch (err) { return { content: [ { type: "text", - text: `Error: ${planFilePath} does not exist. Write your plan using the write tool first, then call ${PLAN_SUBMIT_TOOL} again.`, + text: `Error: failed to read ${inputPath}: ${err instanceof Error ? err.message : String(err)}`, }, ], details: { approved: false }, @@ -604,23 +609,21 @@ export default function plannotator(pi: ExtensionAPI): void { content: [ { type: "text", - text: `Error: ${planFilePath} is empty. Write your plan first, then call ${PLAN_SUBMIT_TOOL} again.`, + text: `Error: ${inputPath} is empty. Write your plan first, then call ${PLAN_SUBMIT_TOOL} again.`, }, ], details: { approved: false }, }; } - // Parse checklist items + lastSubmittedPath = inputPath; checklistItems = parseChecklist(planContent); // Non-interactive or no HTML: auto-approve if (!ctx.hasUI || !hasPlanBrowserHtml()) { phase = "executing"; - - await applyPhaseConfig(ctx, { restoreSavedState: true }); - pi.appendEntry("plannotator-execute", { planFilePath }); + pi.appendEntry("plannotator-execute", { lastSubmittedPath }); persistState(); return { content: [ @@ -647,10 +650,8 @@ export default function plannotator(pi: ExtensionAPI): void { if (result.approved) { phase = "executing"; - - await applyPhaseConfig(ctx, { restoreSavedState: true }); - pi.appendEntry("plannotator-execute", { planFilePath }); + pi.appendEntry("plannotator-execute", { lastSubmittedPath }); persistState(); const doneMsg = @@ -663,7 +664,7 @@ export default function plannotator(pi: ExtensionAPI): void { content: [ { type: "text", - text: `Plan approved with notes! You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${result.feedback}\n\nProceed with implementation, incorporating these notes where applicable.`, + text: `Plan approved with notes! You now have full tool access (read, bash, edit, write). Execute the plan in ${inputPath}. ${doneMsg}\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${result.feedback}\n\nProceed with implementation, incorporating these notes where applicable.`, }, ], details: { approved: true, feedback: result.feedback }, @@ -674,7 +675,7 @@ export default function plannotator(pi: ExtensionAPI): void { content: [ { type: "text", - text: `Plan approved. You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}`, + text: `Plan approved. You now have full tool access (read, bash, edit, write). Execute the plan in ${inputPath}. ${doneMsg}`, }, ], details: { approved: true }, @@ -682,13 +683,14 @@ export default function plannotator(pi: ExtensionAPI): void { } // Denied + persistState(); const feedbackText = result.feedback || "Plan rejected. Please revise."; return { content: [ { type: "text", text: planDenyFeedback(feedbackText, PLAN_SUBMIT_TOOL, { - planFilePath, + planFilePath: inputPath, }), }, ], @@ -699,39 +701,29 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Event Handlers ─────────────────────────────────────────────────── - // Gate writes during planning + // Gate writes during planning — only markdown files inside cwd. pi.on("tool_call", async (event, ctx) => { if (phase !== "planning") return; + if (event.toolName !== "write" && event.toolName !== "edit") return; - if (event.toolName === "write") { - const targetPath = resolve(ctx.cwd, event.input.path as string); - const allowedPath = resolvePlanPath(ctx.cwd); - if (targetPath !== allowedPath) { - return { - block: true, - reason: `Plannotator: writes are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`, - }; - } - } - - if (event.toolName === "edit") { - const targetPath = resolve(ctx.cwd, event.input.path as string); - const allowedPath = resolvePlanPath(ctx.cwd); - if (targetPath !== allowedPath) { - return { - block: true, - reason: `Plannotator: edits are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`, - }; - } + const inputPath = event.input.path as string; + if (!isPlanWritePathAllowed(inputPath, ctx.cwd)) { + const verb = event.toolName === "write" ? "writes" : "edits"; + return { + block: true, + reason: `Plannotator: during planning, ${verb} are limited to markdown files (.md, .mdx) inside the working directory. Blocked: ${inputPath}`, + }; } }); // Inject phase-specific context pi.on("before_agent_start", async (_event, ctx) => { const profile = getPhaseProfile(); - if (phase === "executing") { + const planRef = lastSubmittedPath ?? "your plan file"; + + if (phase === "executing" && lastSubmittedPath) { // Re-read from disk each turn to stay current - const fullPath = resolvePlanPath(ctx.cwd); + const fullPath = resolve(ctx.cwd, lastSubmittedPath); try { const planContent = readFileSync(fullPath, "utf-8"); checklistItems = parseChecklist(planContent); @@ -745,7 +737,7 @@ export default function plannotator(pi: ExtensionAPI): void { const rendered = renderTemplate( profile.systemPrompt, buildPromptVariables({ - planFilePath, + planFilePath: planRef, phase, todoList: todoStats.todoList, completedCount: todoStats.completedCount, @@ -768,22 +760,26 @@ export default function plannotator(pi: ExtensionAPI): void { message: { customType: "plannotator-context", content: `[PLANNOTATOR - PLANNING PHASE] -You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}. +You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. During planning you may only write or edit markdown files (.md, .mdx) inside the working directory. -Available tools: read, bash, grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), ${PLAN_SUBMIT_TOOL} +Available tools: read, bash, grep, find, ls, write (markdown only), edit (markdown only), ${PLAN_SUBMIT_TOOL} Do not run destructive bash commands (rm, git push, npm install, etc.) — focus on reading and exploring the codebase. Web fetching (curl, wget) is fine. ## Iterative Planning Workflow -You are pair-planning with the user. Explore the code to build context, then write your findings into ${planFilePath} as you go. The plan starts as a rough skeleton and gradually becomes the final plan. +You are pair-planning with the user. Explore the code to build context, then write your findings into a markdown plan file as you go. The plan starts as a rough skeleton and gradually becomes the final plan. + +### Picking a plan file + +Choose a descriptive filename for your plan. Convention: \`PLAN.md\` at the repo root for a single focused plan, or \`plans/.md\` for projects that keep multiple plans. Reuse the same filename across revisions of the same plan so version history links up. ### The Loop Repeat this cycle until the plan is complete: 1. **Explore** — Use read, grep, find, ls, and bash to understand the codebase. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist. -2. **Update the plan file** — After each discovery, immediately capture what you learned in ${planFilePath}. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates. +2. **Update the plan file** — After each discovery, immediately capture what you learned in the plan. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates. 3. **Ask the user** — When you hit an ambiguity or decision you can't resolve from code alone, ask. Then go back to step 1. ### First Turn @@ -813,14 +809,14 @@ Keep the plan concise enough to scan quickly, but detailed enough to execute eff ### When to Submit -Your plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call ${PLAN_SUBMIT_TOOL} to submit for review. +Your plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call ${PLAN_SUBMIT_TOOL} with the path to your plan file to submit for review. ### Revising After Feedback When the user denies a plan with feedback: -1. Read ${planFilePath} to see the current plan. +1. Read the plan file to see the current plan. 2. Use the edit tool to make targeted changes addressing the feedback — do NOT rewrite the entire file. -3. Call ${PLAN_SUBMIT_TOOL} again to resubmit. +3. Call ${PLAN_SUBMIT_TOOL} again with the same filePath to resubmit. ### Ending Your Turn @@ -835,16 +831,6 @@ Do not end your turn without doing one of these two things.`, } if (phase === "executing" && checklistItems.length > 0) { - // Re-read from disk each turn to stay current - const fullPath = resolvePlanPath(ctx.cwd); - let planContent = ""; - try { - planContent = readFileSync(fullPath, "utf-8"); - checklistItems = parseChecklist(planContent); - } catch { - // File deleted during execution — degrade gracefully - } - const remaining = checklistItems.filter((t) => !t.completed); if (remaining.length > 0) { const todoList = remaining @@ -854,7 +840,7 @@ Do not end your turn without doing one of these two things.`, message: { customType: "plannotator-context", content: `[PLANNOTATOR - EXECUTING PLAN] -Full tool access is enabled. Execute the plan from ${planFilePath}. +Full tool access is enabled. Execute the plan from ${planRef}. Remaining steps: ${todoList} @@ -924,7 +910,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re ); phase = "idle"; checklistItems = []; - + lastSubmittedPath = null; await restoreSavedState(ctx); savedState = null; @@ -936,12 +922,6 @@ Execute each step in order. After completing a step, include [DONE:n] in your re // Restore state on session start/resume pi.on("session_start", async (_event, ctx) => { - // Resolve plan file path from flag - const flagPlanFile = pi.getFlag("plan-file") as string; - if (flagPlanFile) { - planFilePath = flagPlanFile; - } - const loadedConfig = loadPlannotatorConfig(ctx.cwd); plannotatorConfig = loadedConfig.config; for (const warning of loadedConfig.warnings) { @@ -960,48 +940,50 @@ Execute each step in order. After completing a step, include [DONE:n] in your re (e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plannotator", ) - - .pop() as { data?: PersistedPlannotatorState } | undefined; if (stateEntry?.data) { phase = stateEntry.data.phase ?? phase; - planFilePath = stateEntry.data.planFilePath ?? planFilePath; - - + lastSubmittedPath = stateEntry.data.lastSubmittedPath ?? lastSubmittedPath; savedState = stateEntry.data.savedState ?? savedState; } // Rebuild execution state from disk + session messages if (phase === "executing") { - const fullPath = resolvePlanPath(ctx.cwd); - if (existsSync(fullPath)) { - const content = readFileSync(fullPath, "utf-8"); - checklistItems = parseChecklist(content); - - // Find last execution marker and scan messages after it for [DONE:n] - let executeIndex = -1; - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i] as { type: string; customType?: string }; - if (entry.customType === "plannotator-execute") { - executeIndex = i; - break; + if (lastSubmittedPath) { + const fullPath = resolve(ctx.cwd, lastSubmittedPath); + if (existsSync(fullPath)) { + const content = readFileSync(fullPath, "utf-8"); + checklistItems = parseChecklist(content); + + // Find last execution marker and scan messages after it for [DONE:n] + let executeIndex = -1; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] as { type: string; customType?: string }; + if (entry.customType === "plannotator-execute") { + executeIndex = i; + break; + } } - } - for (let i = executeIndex + 1; i < entries.length; i++) { - const entry = entries[i]; - if ( - entry.type === "message" && - "message" in entry && - isAssistantMessage(entry.message as AssistantMessageLike) - ) { - const text = getTextContent(entry.message as { content: AssistantTextBlock[] }); - markCompletedSteps(text, checklistItems); + for (let i = executeIndex + 1; i < entries.length; i++) { + const entry = entries[i]; + if ( + entry.type === "message" && + "message" in entry && + isAssistantMessage(entry.message as AssistantMessageLike) + ) { + const text = getTextContent(entry.message as { content: AssistantTextBlock[] }); + markCompletedSteps(text, checklistItems); + } } + } else { + // Plan file gone — fall back to idle + phase = "idle"; + lastSubmittedPath = null; } } else { - // Plan file gone — fall back to idle + // No path recorded — can't rebuild, fall back to idle phase = "idle"; } } diff --git a/apps/pi-extension/plannotator.json b/apps/pi-extension/plannotator.json index fec0f231..3351c6ad 100644 --- a/apps/pi-extension/plannotator.json +++ b/apps/pi-extension/plannotator.json @@ -3,7 +3,7 @@ "planning": { "activeTools": ["grep", "find", "ls", "plannotator_submit_plan"], "statusLabel": "⏸ plan", - "systemPrompt": "[PLANNOTATOR - PLANNING PHASE]\nYou are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}.\n\nAvailable tools: read, bash, grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), plannotator_submit_plan\n\nDo not run destructive bash commands (rm, git push, npm install, etc.) — focus on reading and exploring the codebase. Web fetching (curl, wget) is fine.\n\n## Iterative Planning Workflow\n\nYou are pair-planning with the user. Explore the code to build context, then write your findings into ${planFilePath} as you go. The plan starts as a rough skeleton and gradually becomes the final plan.\n\n### The Loop\n\nRepeat this cycle until the plan is complete:\n\n1. **Explore** — Use read, grep, find, ls, and bash to understand the codebase. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist.\n2. **Update the plan file** — After each discovery, immediately capture what you learned in ${planFilePath}. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates.\n3. **Ask the user** — When you hit an ambiguity or decision you can't resolve from code alone, ask. Then go back to step 1.\n\n### First Turn\n\nStart by quickly scanning key files to form an initial understanding of the task scope. Then write a skeleton plan (headers and rough notes) and ask the user your first round of questions. Don't explore exhaustively before engaging the user.\n\n### Asking Good Questions\n\n- Never ask what you could find out by reading the code.\n- Batch related questions together.\n- Focus on things only the user can answer: requirements, preferences, tradeoffs, edge-case priorities.\n- Scale depth to the task — a vague feature request needs many rounds; a focused bug fix may have one or none.\n\n### Plan File Structure\n\nYour plan file should use markdown with clear sections:\n- **Context** — Why this change is being made: the problem, what prompted it, the intended outcome.\n- **Approach** — Your recommended approach only, not all alternatives considered.\n- **Files to modify** — List the critical file paths that will be changed.\n- **Reuse** — Reference existing functions and utilities you found, with their file paths.\n- **Steps** — Implementation checklist:\n - [ ] Step 1 description\n - [ ] Step 2 description\n- **Verification** — How to test the changes end-to-end (run the code, run tests, manual checks).\n\nKeep the plan concise enough to scan quickly, but detailed enough to execute effectively.\n\n### When to Submit\n\nYour plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call plannotator_submit_plan to submit for review.\n\n### Revising After Feedback\n\nWhen the user denies a plan with feedback:\n1. Read ${planFilePath} to see the current plan.\n2. Use the edit tool to make targeted changes addressing the feedback — do NOT rewrite the entire file.\n3. Call plannotator_submit_plan again to resubmit.\n\n### Ending Your Turn\n\nYour turn should only end by either:\n- Asking the user a question to gather more information.\n- Calling plannotator_submit_plan when the plan is ready for review.\n\nDo not end your turn without doing one of these two things." + "systemPrompt": "[PLANNOTATOR - PLANNING PHASE]\nYou are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. During planning you may only write or edit markdown files (.md, .mdx) inside the working directory.\n\nAvailable tools: read, bash, grep, find, ls, write (markdown only), edit (markdown only), plannotator_submit_plan\n\nDo not run destructive bash commands (rm, git push, npm install, etc.) — focus on reading and exploring the codebase. Web fetching (curl, wget) is fine.\n\n## Iterative Planning Workflow\n\nYou are pair-planning with the user. Explore the code to build context, then write your findings into a markdown plan file as you go. The plan starts as a rough skeleton and gradually becomes the final plan.\n\n### Picking a plan file\n\nChoose a descriptive filename for your plan. Convention: `PLAN.md` at the repo root for a single focused plan, or `plans/.md` for projects that keep multiple plans. Reuse the same filename across revisions of the same plan so version history links up.\n\n### The Loop\n\nRepeat this cycle until the plan is complete:\n\n1. **Explore** — Use read, grep, find, ls, and bash to understand the codebase. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist.\n2. **Update the plan file** — After each discovery, immediately capture what you learned in the plan. Don't wait until the end. Use write for the initial draft, then edit for all subsequent updates.\n3. **Ask the user** — When you hit an ambiguity or decision you can't resolve from code alone, ask. Then go back to step 1.\n\n### First Turn\n\nStart by quickly scanning key files to form an initial understanding of the task scope. Then write a skeleton plan (headers and rough notes) and ask the user your first round of questions. Don't explore exhaustively before engaging the user.\n\n### Asking Good Questions\n\n- Never ask what you could find out by reading the code.\n- Batch related questions together.\n- Focus on things only the user can answer: requirements, preferences, tradeoffs, edge-case priorities.\n- Scale depth to the task — a vague feature request needs many rounds; a focused bug fix may need one or none.\n\n### Plan File Structure\n\nYour plan file should use markdown with clear sections:\n- **Context** — Why this change is being made: the problem, what prompted it, the intended outcome.\n- **Approach** — Your recommended approach only, not all alternatives considered.\n- **Files to modify** — List the critical file paths that will be changed.\n- **Reuse** — Reference existing functions and utilities you found, with their file paths.\n- **Steps** — Implementation checklist:\n - [ ] Step 1 description\n - [ ] Step 2 description\n- **Verification** — How to test the changes end-to-end (run the code, run tests, manual checks).\n\nKeep the plan concise enough to scan quickly, but detailed enough to execute effectively.\n\n### When to Submit\n\nYour plan is ready when you've addressed all ambiguities and it covers: what to change, which files to modify, what existing code to reuse, and how to verify. Call plannotator_submit_plan with the path to your plan file to submit for review.\n\n### Revising After Feedback\n\nWhen the user denies a plan with feedback:\n1. Read the plan file to see the current plan.\n2. Use the edit tool to make targeted changes addressing the feedback — do NOT rewrite the entire file.\n3. Call plannotator_submit_plan again with the same filePath to resubmit.\n\n### Ending Your Turn\n\nYour turn should only end by either:\n- Asking the user a question to gather more information.\n- Calling plannotator_submit_plan when the plan is ready for review.\n\nDo not end your turn without doing one of these two things." }, "executing": { "systemPrompt": "[PLANNOTATOR - EXECUTING PLAN]\nFull tool access is enabled. Execute the plan from ${planFilePath}.\n\nRemaining steps:\n${todoList}\n\nExecute each step in order. After completing a step, include [DONE:n] in your response where n is the step number." diff --git a/apps/pi-extension/tool-scope.test.ts b/apps/pi-extension/tool-scope.test.ts index a8434ad8..a93e29a7 100644 --- a/apps/pi-extension/tool-scope.test.ts +++ b/apps/pi-extension/tool-scope.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { getToolsForPhase, + isPlanWritePathAllowed, PLAN_SUBMIT_TOOL, stripPlanningOnlyTools, } from "./tool-scope"; @@ -44,3 +45,44 @@ describe("pi plan tool scoping", () => { ]); }); }); + +describe("plan write path gate", () => { + const cwd = "/r"; + + test("allows markdown files anywhere inside cwd", () => { + expect(isPlanWritePathAllowed("PLAN.md", cwd)).toBe(true); + expect(isPlanWritePathAllowed("plans/auth.md", cwd)).toBe(true); + expect(isPlanWritePathAllowed("deeply/nested/dir/notes.mdx", cwd)).toBe(true); + }); + + test("rejects non-markdown extensions", () => { + expect(isPlanWritePathAllowed("src/app.ts", cwd)).toBe(false); + expect(isPlanWritePathAllowed("notes.txt", cwd)).toBe(false); + expect(isPlanWritePathAllowed("config.json", cwd)).toBe(false); + }); + + test("rejects files with no extension or bare directories", () => { + expect(isPlanWritePathAllowed("plans", cwd)).toBe(false); + expect(isPlanWritePathAllowed("PLAN", cwd)).toBe(false); + }); + + test("rejects traversal and absolute paths outside cwd", () => { + expect(isPlanWritePathAllowed("../escape.md", cwd)).toBe(false); + expect(isPlanWritePathAllowed("../../etc/passwd.md", cwd)).toBe(false); + expect(isPlanWritePathAllowed("/tmp/leak.md", cwd)).toBe(false); + }); + + test("allows absolute paths that resolve inside cwd", () => { + expect(isPlanWritePathAllowed("/r/plans/foo.md", cwd)).toBe(true); + }); + + test("rejects empty path and the cwd itself", () => { + expect(isPlanWritePathAllowed("", cwd)).toBe(false); + expect(isPlanWritePathAllowed(".", cwd)).toBe(false); + }); + + test("extension check is case-insensitive", () => { + expect(isPlanWritePathAllowed("PLAN.MD", cwd)).toBe(true); + expect(isPlanWritePathAllowed("notes.MdX", cwd)).toBe(true); + }); +}); diff --git a/apps/pi-extension/tool-scope.ts b/apps/pi-extension/tool-scope.ts index c229fed0..a1a12238 100644 --- a/apps/pi-extension/tool-scope.ts +++ b/apps/pi-extension/tool-scope.ts @@ -1,9 +1,12 @@ +import { extname, isAbsolute, relative, resolve } from "node:path"; + export type Phase = "idle" | "planning" | "executing"; export const PLAN_SUBMIT_TOOL = "plannotator_submit_plan"; export const PLANNING_DISCOVERY_TOOLS = ["grep", "find", "ls"] as const; const PLANNING_ONLY_TOOLS = new Set([PLAN_SUBMIT_TOOL]); +const ALLOWED_PLAN_EXTENSIONS = new Set([".md", ".mdx"]); export function stripPlanningOnlyTools(tools: readonly string[]): string[] { return tools.filter((tool) => !PLANNING_ONLY_TOOLS.has(tool)); @@ -22,3 +25,15 @@ export function getToolsForPhase( ...new Set([...tools, ...PLANNING_DISCOVERY_TOOLS, PLAN_SUBMIT_TOOL]), ]; } + +// Used by both the planning-phase write gate and plannotator_submit_plan. +// Path must resolve inside cwd (no traversal, no absolute escape) and end +// in a permitted markdown extension. +export function isPlanWritePathAllowed(inputPath: string, cwd: string): boolean { + if (!inputPath) return false; + const targetAbs = resolve(cwd, inputPath); + const rel = relative(resolve(cwd), targetAbs); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return false; + const ext = extname(targetAbs).toLowerCase(); + return ALLOWED_PLAN_EXTENSIONS.has(ext); +}