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}` };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 07:40:48 -07:00
|
|
|
// 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 01:42:56 +01:00
|
|
|
// ── 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);
|
|
|
|
|
}
|