Skip to content

Commit 76c71e1

Browse files
committed
fix: treat external plan edits as review feedback
1 parent b3179f7 commit 76c71e1

3 files changed

Lines changed: 252 additions & 6 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ If you want reproducible installs instead of automatic plugin refreshes, pin an
4141
- exposes a `plan_prompt` tool so the `plan` agent can reveal the plugin's prompt basis for customization
4242
- exposes an `edit_plan` tool so the `plan` agent can open the current plan in the configured external editor
4343
- uses `submit_plan` for review when available, otherwise falls back to external-editor review
44+
- keeps the agent in planner mode if the plan file changed after `submit_plan`; the revised plan must be resubmitted before `plan_exit`
4445
- can leave planner mode with `plan_exit` after approval when experimental plan mode is enabled in the CLI runtime
4546

4647
## Customize the plan agent
@@ -87,6 +88,10 @@ Example:
8788
If submit_plan is unavailable, call edit_plan so I can review the plan in my editor.
8889
```
8990

91+
If you want to reopen the same plan after an initial review pass, prompt the `plan` agent with something like `edit the plan again externally`. That will cause it to call `edit_plan` again and reopen the current plan in the configured editor.
92+
93+
When the editor closes, `edit_plan` compares the plan before and after editing. If nothing changed, it reports that no changes were made. If the user edited the plan, the tool returns the previous and updated plan content so the `plan` agent can treat that as review feedback, summarize the edits, and continue planning from the revised plan.
94+
9095
`edit_plan` uses `PLAN_VISUAL` first, then `VISUAL`, then `EDITOR`. `PLAN_VISUAL` is useful when you want planner review to use a different editor from the rest of your shell tools. The command must launch a separate process and block until editing is complete.
9196

9297
Compatible examples:
@@ -109,6 +114,8 @@ Examples:
109114

110115
If `edit_plan` fails, the `plan` agent should fall back to telling you the plan file path and asking for review in chat.
111116

117+
If you edit the plan after calling `submit_plan`, the plugin treats that as a new draft. In that case the agent should stay in planner mode and call `submit_plan` again before `plan_exit`.
118+
112119
## Auto-updates
113120

114121
OpenCode installs and updates npm plugins automatically. During the beta phase of this plugin, `opencode-planner@beta` gives the smoothest update path for most users.

index.js

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto"
12
import { spawn } from "node:child_process"
23
import { readFile } from "node:fs/promises"
34
import path from "path"
@@ -26,6 +27,7 @@ function reviewInstruction(target) {
2627
return [
2728
`When the plan is complete, if the submit_plan tool is available, use it to submit the plan for review.`,
2829
`Otherwise, call edit_plan to open the markdown plan in the configured external editor for review. If edit_plan fails, tell the user the plan is ready at ${target} and ask for review in chat.`,
30+
`If edit_plan reports that the user changed the plan externally, treat that as review feedback on the plan, summarize the changes, and continue planning from the revised plan.`,
2931
].join(" ")
3032
}
3133

@@ -41,6 +43,7 @@ function agentPrompt(target = defaultPlanTarget) {
4143
...(planExit
4244
? [
4345
"After approval, if the user or Plannotator says something like 'Proceed with implementation', call plan_exit to hand off back to implementation mode.",
46+
"If the plan changes after submit_plan, stay in planner mode, update the plan as needed, and call submit_plan again before plan_exit.",
4447
]
4548
: []),
4649
].join("\n\n")
@@ -89,6 +92,7 @@ function note(id) {
8992
out.length - 1,
9093
0,
9194
"If the user or Plannotator then says something like 'Proceed with implementation', call the plan_exit tool to leave planner mode.",
95+
"If the plan changed after submit_plan, do not call plan_exit yet. Revise as needed and call submit_plan again first.",
9296
)
9397
}
9498

@@ -136,6 +140,67 @@ function editorCommand() {
136140
return process.env.PLAN_VISUAL?.trim() || process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || ""
137141
}
138142

143+
function hashPlan(content) {
144+
return createHash("sha256").update(content).digest("hex")
145+
}
146+
147+
async function readIfExists(target) {
148+
try {
149+
return await readFile(target, "utf8")
150+
} catch (error) {
151+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
152+
return null
153+
}
154+
155+
throw error
156+
}
157+
}
158+
159+
function formatPlanBlock(title, content, fallback) {
160+
return [title, "````markdown", content && content.trim() ? content : fallback, "````"].join("\n")
161+
}
162+
163+
async function snapshotSubmittedPlan(sessionID, args) {
164+
const defaultTarget = file(sessionID)
165+
const currentFile = await readIfExists(defaultTarget)
166+
if (currentFile !== null) {
167+
return {
168+
target: defaultTarget,
169+
hash: hashPlan(currentFile),
170+
}
171+
}
172+
173+
const submitted = typeof args?.plan === "string" ? args.plan : ""
174+
if (!submitted.trim()) return null
175+
176+
if (path.isAbsolute(submitted)) {
177+
const content = await readIfExists(submitted)
178+
if (content !== null) {
179+
return {
180+
target: submitted,
181+
hash: hashPlan(content),
182+
}
183+
}
184+
}
185+
186+
return {
187+
target: null,
188+
hash: hashPlan(submitted),
189+
}
190+
}
191+
192+
async function planChangedSinceSubmit(sessionID, submitted) {
193+
if (!submitted) return false
194+
195+
const target = submitted.target ?? file(sessionID)
196+
const current = await readIfExists(target)
197+
if (current === null) {
198+
return submitted.target !== null
199+
}
200+
201+
return hashPlan(current) !== submitted.hash
202+
}
203+
139204
function runEditor(target) {
140205
const editor = editorCommand()
141206
if (!editor) {
@@ -177,12 +242,13 @@ function runEditor(target) {
177242

178243
async function editPlan(sessionID) {
179244
const target = file(sessionID ?? "<session-id>")
245+
const before = await readIfExists(target)
180246
await runEditor(target)
181247

182-
let content = ""
248+
let after = ""
183249

184250
try {
185-
content = await readFile(target, "utf8")
251+
after = await readFile(target, "utf8")
186252
} catch (error) {
187253
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
188254
throw new Error(`The plan file \`${target}\` does not exist yet. Finish writing the plan first.`)
@@ -191,8 +257,25 @@ async function editPlan(sessionID) {
191257
throw error
192258
}
193259

194-
const rendered = content.trim() ? content : `The plan file \`${target}\` is empty.`
195-
return [`The plan was edited in your external editor.`, `File: ${target}`, "", rendered].join("\n")
260+
if (before === after) {
261+
return [
262+
"The plan was reopened in your external editor. No changes were made.",
263+
`File: ${target}`,
264+
"",
265+
formatPlanBlock("## Current plan", after, `The plan file \`${target}\` is empty.`),
266+
].join("\n")
267+
}
268+
269+
return [
270+
"The user edited the plan externally.",
271+
`File: ${target}`,
272+
"",
273+
"Treat these external edits as review feedback on the plan. Summarize what changed, continue planning from the updated plan, and if this plan was already reviewed with submit_plan, submit the revised plan again before plan_exit.",
274+
"",
275+
formatPlanBlock("## Previous plan", before, "_(The plan file did not exist before editing.)_"),
276+
"",
277+
formatPlanBlock("## Updated plan", after, `The plan file \`${target}\` is empty.`),
278+
].join("\n")
196279
}
197280

198281
function mode(input = {}) {
@@ -249,6 +332,7 @@ function mode(input = {}) {
249332

250333
export default async function plannerPlugin() {
251334
const seen = new Set()
335+
const submittedPlans = new Map()
252336

253337
return {
254338
tool: {
@@ -289,6 +373,22 @@ export default async function plannerPlugin() {
289373
synthetic: true,
290374
})
291375
},
376+
async "tool.execute.before"(input) {
377+
if (input.tool !== "plan_exit") return
378+
379+
const submitted = submittedPlans.get(input.sessionID)
380+
if (!(await planChangedSinceSubmit(input.sessionID, submitted))) return
381+
382+
throw new Error(
383+
"The plan has changed since the last submit_plan review. Stay in planner mode, update the plan as needed, and call submit_plan again before plan_exit.",
384+
)
385+
},
386+
async "tool.execute.after"(input) {
387+
if (input.tool !== "submit_plan") return
388+
389+
const snapshot = await snapshotSubmittedPlan(input.sessionID, input.args)
390+
if (snapshot) submittedPlans.set(input.sessionID, snapshot)
391+
},
292392
async "experimental.chat.system.transform"(input, output) {
293393
if (!input.sessionID || !seen.has(input.sessionID)) return
294394
output.system.push(note(input.sessionID))

0 commit comments

Comments
 (0)