Files
backnotprop__plannotator/apps/opencode-plugin/plan-mode.ts
T
Robert Dailey 807cc5f09b feat(submit-plan): replace dual-mode with edit-based interface (#730)
* feat(submit-plan): replace text/file-path mode with edit-based interface

Switches the OpenCode submit_plan tool from a dual-mode interface
(inline text or file path) to an edit-based one. The plugin now owns a
backing file at .opencode/plans/_active-plan.md; the agent never reads
or writes it directly. On denial, the response includes the current plan
with line numbers so the agent can apply surgical edits instead of
resubmitting the entire document, reducing token waste on iterative
revisions.

- Add applyEdits, validateEdits, formatWithLineNumbers, and
  getPlanBackingPath helpers to the plugin
- Validate edit ranges (bounds, overlap, size limit) before mutating the
  backing file
- Return line-numbered plan in denial responses to anchor targeted edits
- Remove getPlanDirectory, validatePlanPath, and file-path auto-
  detection from plan-mode.ts
- Replace plan-mode.test.ts path-validation coverage with submit-
  plan.test.ts for the edit engine
- Update custom-feedback.md and opencode.md docs for edit-based
  semantics

Refs #365

* chore(opencode): drop unused buildPlanFileRule import

Removed in PR #730 deny path along with the only call site, but the
import was left behind.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-05-15 09:54:04 -07:00

57 lines
2.1 KiB
TypeScript

// ── 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 ?? {};
}
// ── 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);
}