Skip to content

Commit ebdc236

Browse files
committed
feat: add planner runtime fallbacks and debug tools
1 parent f75894a commit ebdc236

7 files changed

Lines changed: 370 additions & 49 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
npm-debug.log*
33
*.tgz
44
.idea/
5+
.opencode/

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# opencode-planner
22

33
`opencode-planner` is an OpenCode plugin that adds a dedicated `plan` agent for read-only planning before implementation. It's based on the experimental plan agent. That is, it likes to use sub-agents and a structured approach to planning.
4-
It asks clarifying questions, and produces a markdown file. This plugin will call-out to plannotator for elegant viewing and editing.
4+
It asks clarifying questions, and produces a markdown file. When Plannotator is installed, it can submit the finished plan for richer review. Without Plannotator, it falls back to a normal chat-based review handoff.
55

6-
The plugin requires the user to switch back to the Build agent.
6+
After review, the agent can hand back to implementation mode by calling `plan_exit` only when the host runtime exposes that tool. In current OpenCode builds, that means experimental plan mode must be enabled and the client must be `cli`.
77

88
Repository: <https://github.com/timrichardson/opencode-planner>
99

@@ -34,6 +34,8 @@ If you want reproducible installs instead of automatic plugin refreshes, pin an
3434
- adds a `plan` agent intended for design and implementation planning
3535
- constrains that agent to read-only tools plus markdown plan editing
3636
- injects a system reminder that keeps the planning workflow explicit
37+
- uses `submit_plan` for review when available, otherwise falls back to manual chat review
38+
- can leave planner mode with `plan_exit` after approval when experimental plan mode is enabled in the CLI runtime
3739

3840
## Auto-updates
3941

@@ -51,8 +53,25 @@ If OpenCode appears to keep an older cached plugin, clear the cache under `~/.ca
5153

5254
```bash
5355
npm test
56+
npm run debug:plan
57+
npm run opencode:no-plannotator -- debug config
5458
```
5559

60+
`npm run debug:plan` checks the active OpenCode runtime and reports whether the local repo plugin is loaded, whether `submit_plan` and `plan_exit` are allowed by the `plan` agent, and whether they are actually registered as runtime tools.
61+
62+
This is the fastest way to distinguish:
63+
64+
- prompt/config issues inside this repo
65+
- runtime tool-registration issues in OpenCode or Plannotator
66+
67+
To test this plugin without the globally installed Plannotator plugin, use the sandbox launcher:
68+
69+
```bash
70+
npm run opencode:no-plannotator
71+
```
72+
73+
It starts OpenCode with an isolated temporary home/config, keeps the local repo plugin loaded, and filters out `@plannotator/opencode` from the plugin list without changing your real global config.
74+
5675
## Release process
5776

5877
1. Update `CHANGELOG.md`.

index.js

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,48 @@ import path from "path"
33
const agent = "plan"
44
const root = ".opencode/plans"
55

6+
function truthy(key) {
7+
const value = process.env[key]?.toLowerCase()
8+
return value === "true" || value === "1"
9+
}
10+
11+
function hasPlanExit() {
12+
const experimentalPlanMode = truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
13+
const client = process.env.OPENCODE_CLIENT ?? "cli"
14+
return experimentalPlanMode && client === "cli"
15+
}
16+
617
function file(id) {
718
return path.posix.join(root, `${id}.md`)
819
}
920

10-
function note(id) {
21+
function reviewInstruction(target) {
1122
return [
23+
`When the plan is complete, if the submit_plan tool is available, use it to submit the plan for review.`,
24+
`Otherwise, tell the user the plan is ready at ${target} and ask for review in chat.`,
25+
].join(" ")
26+
}
27+
28+
function note(id) {
29+
const out = [
1230
"<system-reminder>",
1331
"Planner mode is active.",
1432
"You must not edit source files, run bash, change config, or make commits.",
1533
"You may only use read-only tools, ask clarifying questions, delegate exploration or design with the task tool, and edit allowed markdown plan files.",
1634
`Write the plan to ${file(id)} or another allowed *.plan.md/*.spec.md file.`,
17-
"When the plan is complete, call the submit_plan tool to open Plannotator for review.",
35+
reviewInstruction(file(id)),
1836
"</system-reminder>",
19-
].join("\n")
37+
]
38+
39+
if (hasPlanExit()) {
40+
out.splice(
41+
out.length - 1,
42+
0,
43+
"If the user or Plannotator then says something like 'Proceed with implementation', call the plan_exit tool to leave planner mode.",
44+
)
45+
}
46+
47+
return out.join("\n")
2048
}
2149

2250
function partID() {
@@ -46,6 +74,7 @@ function merge(a, b) {
4674
}
4775

4876
function mode(input = {}) {
77+
const planExit = hasPlanExit()
4978
const base = {
5079
mode: "primary",
5180
color: "info",
@@ -55,7 +84,12 @@ function mode(input = {}) {
5584
"Stay in planning mode: inspect the codebase, ask targeted questions when needed, and write a concise execution plan before implementation.",
5685
"Default plan path: .opencode/plans/<session-id>.md.",
5786
"Prefer the task tool with the explore and general subagents for deeper research.",
58-
"Do not stop after writing the plan; call submit_plan to submit the plan for review.",
87+
reviewInstruction(".opencode/plans/<session-id>.md"),
88+
...(planExit
89+
? [
90+
"After approval, if the user or Plannotator says something like 'Proceed with implementation', call plan_exit to hand off back to implementation mode.",
91+
]
92+
: []),
5993
].join("\n\n"),
6094
permission: {
6195
"*": "deny",
@@ -78,6 +112,7 @@ function mode(input = {}) {
78112
codesearch: "allow",
79113
batch: "allow",
80114
submit_plan: "allow",
115+
...(planExit ? { plan_exit: "allow" } : {}),
81116
edit: {
82117
"*": "deny",
83118
[path.posix.join(root, "*.md")]: "allow",

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"LICENSE"
1414
],
1515
"scripts": {
16-
"test": "node --test"
16+
"test": "node --test",
17+
"debug:plan": "node scripts/debug-plan-runtime.js",
18+
"opencode:no-plannotator": "node scripts/run-opencode-sandbox.js --without-plannotator"
1719
},
1820
"keywords": [
1921
"opencode",

scripts/debug-plan-runtime.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { execFileSync } from "node:child_process"
2+
import path from "node:path"
3+
import process from "node:process"
4+
import { fileURLToPath } from "node:url"
5+
6+
const here = path.dirname(fileURLToPath(import.meta.url))
7+
const root = path.resolve(here, "..")
8+
const localPlugin = `file://${path.join(root, "index.js")}`
9+
10+
function runJSON(args) {
11+
try {
12+
const output = execFileSync("opencode", args, {
13+
cwd: root,
14+
encoding: "utf8",
15+
stdio: ["ignore", "pipe", "pipe"],
16+
}).trim()
17+
18+
return JSON.parse(output)
19+
} catch (error) {
20+
const stdout = error.stdout?.toString?.() ?? ""
21+
const stderr = error.stderr?.toString?.() ?? ""
22+
const detail = [stdout, stderr].filter(Boolean).join("\n")
23+
throw new Error(`Failed to run: opencode ${args.join(" ")}\n${detail}`.trim())
24+
}
25+
}
26+
27+
function hasAllowedPermission(permission, entries = []) {
28+
return entries.some((entry) => entry.permission === permission && entry.action === "allow")
29+
}
30+
31+
function line(label, value) {
32+
console.log(`${label.padEnd(32)} ${value}`)
33+
}
34+
35+
const config = runJSON(["debug", "config"])
36+
const plan = runJSON(["debug", "agent", "plan"])
37+
38+
const plugins = config.plugin ?? []
39+
const permissions = plan.permission ?? []
40+
const tools = plan.tools ?? {}
41+
const prompt = plan.prompt ?? ""
42+
43+
const planExitAllowed = hasAllowedPermission("plan_exit", permissions)
44+
const submitPlanAllowed = hasAllowedPermission("submit_plan", permissions)
45+
const planExitTool = Boolean(tools.plan_exit)
46+
const submitPlanTool = Boolean(tools.submit_plan)
47+
const usingLocalPlugin = plugins.includes(localPlugin)
48+
const promptMentionsPlanExit = prompt.includes("plan_exit")
49+
50+
console.log("OpenCode plan runtime check")
51+
console.log("")
52+
line("Repo plugin loaded", usingLocalPlugin ? "yes" : "no")
53+
line("submit_plan allowed", submitPlanAllowed ? "yes" : "no")
54+
line("plan_exit allowed", planExitAllowed ? "yes" : "no")
55+
line("submit_plan tool", submitPlanTool ? "yes" : "no")
56+
line("plan_exit tool", planExitTool ? "yes" : "no")
57+
line("Prompt mentions plan_exit", promptMentionsPlanExit ? "yes" : "no")
58+
line(
59+
"OPENCODE_EXPERIMENTAL_PLAN_MODE",
60+
process.env.OPENCODE_EXPERIMENTAL_PLAN_MODE || "<unset>",
61+
)
62+
line("OPENCODE_CLIENT", process.env.OPENCODE_CLIENT || "<unset>")
63+
64+
console.log("\nActive plugins:")
65+
for (const plugin of plugins) {
66+
console.log(`- ${plugin}`)
67+
}
68+
69+
console.log("\nAssessment:")
70+
if (!usingLocalPlugin) {
71+
console.log(`- OpenCode is not using the local repo plugin at ${localPlugin}.`)
72+
}
73+
if (!submitPlanTool) {
74+
console.log("- submit_plan is not registered as a runtime tool.")
75+
}
76+
if (planExitAllowed && !planExitTool) {
77+
console.log(
78+
"- plan_exit is permitted by the agent config but is not registered as a callable runtime tool.",
79+
)
80+
console.log(
81+
"- Upstream OpenCode only registers plan_exit when OPENCODE_EXPERIMENTAL_PLAN_MODE is enabled and OPENCODE_CLIENT is 'cli'.",
82+
)
83+
} else if (!planExitAllowed) {
84+
console.log("- plan_exit is not allowed by the plan agent config.")
85+
} else {
86+
console.log("- plan_exit is both allowed and registered.")
87+
}

scripts/run-opencode-sandbox.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import fs from "node:fs/promises"
2+
import os from "node:os"
3+
import path from "node:path"
4+
import process from "node:process"
5+
import { spawn } from "node:child_process"
6+
import { fileURLToPath } from "node:url"
7+
8+
const here = path.dirname(fileURLToPath(import.meta.url))
9+
const root = path.resolve(here, "..")
10+
const localPlanner = `file://${path.join(root, "index.js")}`
11+
const globalConfigPath = path.join(os.homedir(), ".config", "opencode", "opencode.json")
12+
13+
function usage() {
14+
console.log(`Usage: node scripts/run-opencode-sandbox.js [--without-plannotator] [opencode args...]
15+
16+
Examples:
17+
npm run opencode:no-plannotator
18+
npm run opencode:no-plannotator -- debug config
19+
OPENCODE_EXPERIMENTAL_PLAN_MODE=1 OPENCODE_CLIENT=cli npm run opencode:no-plannotator -- debug agent plan`)
20+
}
21+
22+
function normalizePlugin(entry) {
23+
return Array.isArray(entry) ? entry[0] : entry
24+
}
25+
26+
function parseLooseJSON(text) {
27+
return JSON.parse(
28+
text
29+
.replace(/^\uFEFF/, "")
30+
.replace(/,\s*([}\]])/g, "$1"),
31+
)
32+
}
33+
34+
function keepWithoutPlannotator(entry) {
35+
const id = normalizePlugin(entry)
36+
return id !== "@plannotator/opencode@latest" && id !== "@plannotator/opencode"
37+
}
38+
39+
const rawArgs = process.argv.slice(2)
40+
if (rawArgs.includes("-h") || rawArgs.includes("--help")) {
41+
usage()
42+
process.exit(0)
43+
}
44+
45+
const stripPlannotator = rawArgs.includes("--without-plannotator")
46+
const opencodeArgs = rawArgs.filter((arg) => arg !== "--without-plannotator")
47+
48+
const baseConfig = parseLooseJSON(await fs.readFile(globalConfigPath, "utf8"))
49+
const plugin = (baseConfig.plugin ?? []).filter((entry) => !stripPlannotator || keepWithoutPlannotator(entry))
50+
51+
const filtered = plugin.filter((entry) => normalizePlugin(entry) !== localPlanner)
52+
filtered.push(localPlanner)
53+
54+
const sandboxConfig = {
55+
...baseConfig,
56+
plugin: filtered,
57+
}
58+
59+
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-planner-"))
60+
const configDir = path.join(sandboxRoot, ".config", "opencode")
61+
const dataDir = path.join(sandboxRoot, ".local", "share")
62+
const stateDir = path.join(sandboxRoot, ".local", "state")
63+
const cacheDir = path.join(sandboxRoot, ".cache")
64+
65+
await fs.mkdir(configDir, { recursive: true })
66+
await fs.mkdir(dataDir, { recursive: true })
67+
await fs.mkdir(stateDir, { recursive: true })
68+
await fs.mkdir(cacheDir, { recursive: true })
69+
70+
const sandboxConfigPath = path.join(sandboxRoot, "opencode.sandbox.json")
71+
await fs.writeFile(sandboxConfigPath, `${JSON.stringify(sandboxConfig, null, 2)}\n`)
72+
73+
console.log(`Sandbox root: ${sandboxRoot}`)
74+
console.log(`Sandbox config: ${sandboxConfigPath}`)
75+
console.log("Active plugins:")
76+
for (const entry of filtered) {
77+
console.log(`- ${normalizePlugin(entry)}`)
78+
}
79+
80+
const child = spawn("opencode", opencodeArgs, {
81+
cwd: root,
82+
stdio: "inherit",
83+
env: {
84+
...process.env,
85+
HOME: sandboxRoot,
86+
XDG_CONFIG_HOME: path.join(sandboxRoot, ".config"),
87+
XDG_DATA_HOME: dataDir,
88+
XDG_STATE_HOME: stateDir,
89+
XDG_CACHE_HOME: cacheDir,
90+
OPENCODE_CONFIG: sandboxConfigPath,
91+
},
92+
})
93+
94+
child.on("exit", (code, signal) => {
95+
if (signal) {
96+
process.kill(process.pid, signal)
97+
return
98+
}
99+
process.exit(code ?? 0)
100+
})

0 commit comments

Comments
 (0)