Files
backnotprop__plannotator/apps/opencode-plugin/plan-mode.ts
T

143 lines
5.0 KiB
TypeScript
Raw Normal View History

feat: Pi-style iterative planning for OpenCode plugin (#318) * feat: extract checklist utilities to shared package Move ChecklistItem, parseChecklist, extractDoneSteps, and markCompletedSteps from Pi extension to @plannotator/shared for reuse by the OpenCode plugin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Pi-style iterative planning for OpenCode plugin Rewrite the OpenCode plugin to match Pi's planning methodology: - Inject rich iterative planning prompt when agent is "plan" (explore → update plan → ask user loop, structured plan format) - File-based submit_plan: reads plan from disk instead of requiring it as a string arg. Resolves path from OpenCode's system prompt, falls back to PLAN.md - Suppress plan_exit via tool.definition hook (directs to submit_plan) - Add PLANNOTATOR_ALLOW_SUBAGENTS env var for #289 - Pass planFilePath to denial feedback template - Keep existing subagent/build/title guards intact Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings in OpenCode plugin - Cache agents list (static per session, was fetched every LLM call) - Remove TOCTOU: drop redundant file.exists() before file.text() - Eliminate duplicate system.join() by reusing joined string - Resolve getSharingEnabled() once in submit_plan instead of twice - Use path.join() instead of string concatenation for file paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: session-scoped plan files and tool-neutral prompt Replace plugin-global resolvedPlanFilePath and prompt-path parsing with per-session plan files at ~/.plannotator/session-plans/opencode/{id}/plan.md. Remove resolvePlanFilePath() regex (fixes spaces-in-path and cross-session race). Make planning prompt tool-neutral (no write/edit references) so it works with apply_patch models. Strip OpenCode's native read-only and experimental-mode prompt lines before injecting Plannotator's planning prompt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: directory-based plan storage with path validation Replace session-scoped plan files with a shared plan directory at ~/.plannotator/session-plans/opencode/. Agent picks the filename, submit_plan takes a path arg and validates it (absolute, inside plan dir, exists, readable, non-empty) with canonical path checks to defeat traversal and symlink escapes. Remove summary and plan string args. Planning prompt is now tool-neutral and directs the agent to reuse the same file on feedback, not create new ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use OpenCode's native plans directory for permission compatibility OpenCode's plan mode permission ruleset only allows edits to .opencode/plans/*.md and $XDG_DATA_HOME/opencode/plans/*.md. Our custom ~/.plannotator/session-plans/opencode/ path was blocked by PermissionDeniedError at the tool level regardless of prompt stripping. Switch to the XDG path that OpenCode already allows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: exploration-first planning prompt Restructure the planning prompt as a phased workflow: Explore → Ask → Write → Submit. The agent now explores the codebase before creating a plan file or asking questions, producing better-researched plans for existing codebases. Greenfield tasks can skip straight to questions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: fix stale path in JSDoc header comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:13:57 -07:00
import { existsSync, readFileSync, realpathSync } from "fs";
import { homedir } from "os";
import path from "path";
// ── Directory ─────────────────────────────────────────────────────────────
/**
* Returns the OpenCode plans directory path.
* Matches OpenCode's own resolution: $XDG_DATA_HOME/opencode/plans
* (defaults to ~/.local/share/opencode/plans on all platforms).
*
* We use this path because OpenCode's plan mode permission ruleset
* only allows edits to .opencode/plans/*.md and this XDG path.
*/
export function getPlanDirectory(): string {
const dataHome = process.env.XDG_DATA_HOME || path.join(homedir(), ".local", "share");
return path.join(dataHome, "opencode", "plans");
}
// ── Path validation ───────────────────────────────────────────────────────
export type ValidatePlanPathResult =
| { ok: true; content: string }
| { ok: false; error: string };
export function validatePlanPath(
filePath: string,
planDir: string,
): ValidatePlanPathResult {
// 1. Must be absolute
if (!path.isAbsolute(filePath)) {
return { ok: false, error: `Path must be absolute. Got: ${filePath}` };
}
// 2. Directory check disabled — allow plans to be written anywhere.
// TODO: revisit if we want to re-scope plan file locations.
// try {
// const canonicalDir = realpathSync(planDir);
// const canonicalFile = realpathSync(path.dirname(filePath)) + path.sep + path.basename(filePath);
// if (!canonicalFile.startsWith(canonicalDir + path.sep) && canonicalFile !== canonicalDir) {
// return {
// ok: false,
// error: `Plan file must be inside ${planDir}. Got: ${filePath}`,
// };
// }
// } catch {
// const normalizedFile = path.normalize(filePath);
// const normalizedDir = path.normalize(planDir);
// if (!normalizedFile.startsWith(normalizedDir + path.sep)) {
// return {
// ok: false,
// error: `Plan file must be inside ${planDir}. Got: ${filePath}`,
// };
// }
// }
feat: Pi-style iterative planning for OpenCode plugin (#318) * feat: extract checklist utilities to shared package Move ChecklistItem, parseChecklist, extractDoneSteps, and markCompletedSteps from Pi extension to @plannotator/shared for reuse by the OpenCode plugin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Pi-style iterative planning for OpenCode plugin Rewrite the OpenCode plugin to match Pi's planning methodology: - Inject rich iterative planning prompt when agent is "plan" (explore → update plan → ask user loop, structured plan format) - File-based submit_plan: reads plan from disk instead of requiring it as a string arg. Resolves path from OpenCode's system prompt, falls back to PLAN.md - Suppress plan_exit via tool.definition hook (directs to submit_plan) - Add PLANNOTATOR_ALLOW_SUBAGENTS env var for #289 - Pass planFilePath to denial feedback template - Keep existing subagent/build/title guards intact Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings in OpenCode plugin - Cache agents list (static per session, was fetched every LLM call) - Remove TOCTOU: drop redundant file.exists() before file.text() - Eliminate duplicate system.join() by reusing joined string - Resolve getSharingEnabled() once in submit_plan instead of twice - Use path.join() instead of string concatenation for file paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: session-scoped plan files and tool-neutral prompt Replace plugin-global resolvedPlanFilePath and prompt-path parsing with per-session plan files at ~/.plannotator/session-plans/opencode/{id}/plan.md. Remove resolvePlanFilePath() regex (fixes spaces-in-path and cross-session race). Make planning prompt tool-neutral (no write/edit references) so it works with apply_patch models. Strip OpenCode's native read-only and experimental-mode prompt lines before injecting Plannotator's planning prompt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: directory-based plan storage with path validation Replace session-scoped plan files with a shared plan directory at ~/.plannotator/session-plans/opencode/. Agent picks the filename, submit_plan takes a path arg and validates it (absolute, inside plan dir, exists, readable, non-empty) with canonical path checks to defeat traversal and symlink escapes. Remove summary and plan string args. Planning prompt is now tool-neutral and directs the agent to reuse the same file on feedback, not create new ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use OpenCode's native plans directory for permission compatibility OpenCode's plan mode permission ruleset only allows edits to .opencode/plans/*.md and $XDG_DATA_HOME/opencode/plans/*.md. Our custom ~/.plannotator/session-plans/opencode/ path was blocked by PermissionDeniedError at the tool level regardless of prompt stripping. Switch to the XDG path that OpenCode already allows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: exploration-first planning prompt Restructure the planning prompt as a phased workflow: Explore → Ask → Write → Submit. The agent now explores the codebase before creating a plan file or asking questions, producing better-researched plans for existing codebases. Greenfield tasks can skip straight to questions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: fix stale path in JSDoc header comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:13:57 -07:00
// 3. Must exist
if (!existsSync(filePath)) {
return {
ok: false,
error: `No plan file found at ${filePath}. Create the file first, then call submit_plan.`,
};
}
// 4. Must be readable
let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch (err) {
return {
ok: false,
error: `Could not read plan file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
};
}
// 5. Must have non-whitespace content
if (!content.trim()) {
return {
ok: false,
error: `Plan file at ${filePath} is empty. Write your plan content first, then call submit_plan.`,
};
}
return { ok: true, content };
}
// ── Permission helpers ────────────────────────────────────────────────────
/**
* Normalize an `edit` permission value before merging additional rules.
*
* OpenCode's zod transform converts legacy `tools: { edit: false }` to
* `permission.edit = "deny"` (a plain string) before any plugin sees the
* config. Spreading a string in JS produces char-index keys:
* `{ ..."deny" }` → `{ "0": "d", "1": "e", "2": "n", "3": "y" }`
* which corrupt the permission ruleset and cause zod validation failures.
*
* This function converts a string action to `{ "*": action }` (equivalent
* wildcard object) so the caller can safely spread it and add overrides.
*/
export function normalizeEditPermission(
edit: string | Record<string, string> | undefined,
): Record<string, string> {
if (typeof edit === "string") {
return { "*": edit };
}
return edit ?? {};
}
feat: Pi-style iterative planning for OpenCode plugin (#318) * feat: extract checklist utilities to shared package Move ChecklistItem, parseChecklist, extractDoneSteps, and markCompletedSteps from Pi extension to @plannotator/shared for reuse by the OpenCode plugin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Pi-style iterative planning for OpenCode plugin Rewrite the OpenCode plugin to match Pi's planning methodology: - Inject rich iterative planning prompt when agent is "plan" (explore → update plan → ask user loop, structured plan format) - File-based submit_plan: reads plan from disk instead of requiring it as a string arg. Resolves path from OpenCode's system prompt, falls back to PLAN.md - Suppress plan_exit via tool.definition hook (directs to submit_plan) - Add PLANNOTATOR_ALLOW_SUBAGENTS env var for #289 - Pass planFilePath to denial feedback template - Keep existing subagent/build/title guards intact Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings in OpenCode plugin - Cache agents list (static per session, was fetched every LLM call) - Remove TOCTOU: drop redundant file.exists() before file.text() - Eliminate duplicate system.join() by reusing joined string - Resolve getSharingEnabled() once in submit_plan instead of twice - Use path.join() instead of string concatenation for file paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: session-scoped plan files and tool-neutral prompt Replace plugin-global resolvedPlanFilePath and prompt-path parsing with per-session plan files at ~/.plannotator/session-plans/opencode/{id}/plan.md. Remove resolvePlanFilePath() regex (fixes spaces-in-path and cross-session race). Make planning prompt tool-neutral (no write/edit references) so it works with apply_patch models. Strip OpenCode's native read-only and experimental-mode prompt lines before injecting Plannotator's planning prompt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: directory-based plan storage with path validation Replace session-scoped plan files with a shared plan directory at ~/.plannotator/session-plans/opencode/. Agent picks the filename, submit_plan takes a path arg and validates it (absolute, inside plan dir, exists, readable, non-empty) with canonical path checks to defeat traversal and symlink escapes. Remove summary and plan string args. Planning prompt is now tool-neutral and directs the agent to reuse the same file on feedback, not create new ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use OpenCode's native plans directory for permission compatibility OpenCode's plan mode permission ruleset only allows edits to .opencode/plans/*.md and $XDG_DATA_HOME/opencode/plans/*.md. Our custom ~/.plannotator/session-plans/opencode/ path was blocked by PermissionDeniedError at the tool level regardless of prompt stripping. Switch to the XDG path that OpenCode already allows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: exploration-first planning prompt Restructure the planning prompt as a phased workflow: Explore → Ask → Write → Submit. The agent now explores the codebase before creating a plan file or asking questions, producing better-researched plans for existing codebases. Greenfield tasks can skip straight to questions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: fix stale path in JSDoc header comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:13:57 -07:00
// ── Prompt stripping ──────────────────────────────────────────────────────
function shouldStripPlanModeLine(line: string): boolean {
const normalized = line.trim().toLowerCase();
return normalized.includes("strictly forbidden: any file edits")
|| normalized.includes("your plan at ")
|| normalized.includes("plan file already exists at ")
|| normalized.includes(".opencode/plans/")
|| normalized.includes("plan_exit")
|| (normalized.includes("agent's conversation") && normalized.includes("not on disk"));
}
function cleanupSystemEntry(entry: string): string {
return entry
.split("\n")
.map((line) => line.replace(/[ \t]+$/g, ""))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function stripConflictingPlanModeRules(systemEntries: string[]): string[] {
return systemEntries
.map((entry) =>
cleanupSystemEntry(
entry
.split("\n")
.filter((line) => !shouldStripPlanModeLine(line))
.join("\n"),
),
)
.filter(Boolean);
}