Skip to content

Commit 67d4661

Browse files
committed
feat: add /edit-plan command and stable release cleanup
1 parent 48a53e8 commit 67d4661

8 files changed

Lines changed: 159 additions & 40 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,10 @@ jobs:
2727
run: |
2828
version="${GITHUB_REF_NAME#v}"
2929
if [[ "$version" == *-* ]]; then
30-
prerelease_part="${version#*-}"
31-
dist_tag="${prerelease_part%%.*}"
32-
echo "prerelease=true" >> "$GITHUB_OUTPUT"
33-
else
34-
dist_tag="latest"
35-
echo "prerelease=false" >> "$GITHUB_OUTPUT"
30+
echo "Only stable release tags are supported: $version" >&2
31+
exit 1
3632
fi
3733
echo "version=$version" >> "$GITHUB_OUTPUT"
38-
echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT"
3934
- name: Ensure tag matches package version
4035
shell: bash
4136
run: |
@@ -44,25 +39,15 @@ jobs:
4439
echo "Tag version ${{ steps.meta.outputs.version }} does not match package.json version $package_version" >&2
4540
exit 1
4641
fi
47-
- run: npm publish --access public --tag "${{ steps.meta.outputs.dist_tag }}"
48-
- name: Mirror stable release to beta dist-tag
49-
if: steps.meta.outputs.dist_tag == 'latest'
50-
run: npm dist-tag add "opencode-planner@${{ steps.meta.outputs.version }}" beta
42+
- run: npm publish --access public --tag latest
5143
- name: Create GitHub release
5244
shell: bash
5345
env:
5446
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55-
PRERELEASE: ${{ steps.meta.outputs.prerelease }}
5647
run: |
5748
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
5849
echo "GitHub release for $GITHUB_REF_NAME already exists."
5950
exit 0
6051
fi
6152
62-
args=(release create "$GITHUB_REF_NAME" --verify-tag --generate-notes)
63-
64-
if [[ "$PRERELEASE" == "true" ]]; then
65-
args+=(--prerelease)
66-
fi
67-
68-
gh "${args[@]}"
53+
gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,5 @@ Use `npm test` for normal code changes. Use the runtime debug and sandbox comman
4949
## Notes For Agents
5050

5151
- This repo currently has no dedicated lint or format script; preserve the existing style manually.
52-
- The package is published from version tags, and the README documents the prerelease `@beta` install path.
52+
- The package is published from version tags, and the README documents the stable install path.
5353
- Changes around `plan_exit` should preserve the current runtime contract: only mention or allow it when experimental plan mode is enabled and the client is `cli`.

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- add an `/edit-plan` command that routes to the `plan` agent and reuses the existing `edit_plan` tool flow
6+
- document `/edit-plan` and surface it in the runtime debug output
7+
38
## 0.2.0
49

5-
- promote `opencode-planner` from beta to its stable release line
6-
- keep the npm `beta` dist-tag aligned with the current stable release until the next prerelease cycle
10+
- promote `opencode-planner` to its stable release line
11+
- publish stable releases to npm `latest`
712

813
## 0.1.1-beta.11
914

README.md

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
# opencode-planner
22

3-
`opencode-planner` is an OpenCode plugin that adds a dedicated `plan` agent for read-only planning before implementation. Its functionality is an emulation of the experimental plan agent (it has no hard dependency on EXPERIMENTAL_PLAN_MODE=1, altough that setting enables a tool called plan_exit which this plugin will use if available). That is, it likes to use sub-agents and a structured approach to planning, asks clarifying questions, and finally it produces a markdown file.
3+
`opencode-planner` is an OpenCode plugin that adds a dedicated `plan` agent for read-only planning before implementation. Its functionality is an emulation of the experimental plan agent (it has no hard dependency on `EXPERIMENTAL_PLAN_MODE=1`, although that setting enables a tool called plan_exit which this plugin will use if available). That is, it likes to use sub-agents and a structured approach to planning, asks clarifying questions, and finally it produces a markdown file.
4+
5+
6+
When Plannotator is installed, it can submit the finished plan for richer review.
7+
8+
Without Plannotator, it can open the plan in your configured external editor for review. A new command `/edit-plan` will open the plan in the editor if needed.
9+
10+
In either case, changes made while editing will trigger a revision of the plan.
411

5-
When Plannotator is installed, it can submit the finished plan for richer review. Without Plannotator, it can open the plan in your configured external editor for review.
612

713
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`. If it's not enabled, you need to prompt the build agent to start work.
814

915
Repository: <https://github.com/timrichardson/opencode-planner>
1016

17+
18+
### Rationale
19+
Experimental plan mode is not a focus for the core devs, who point out that a plugin can do it, which I set out to prove, at least as a concept. This plugin means, at least for me, a development path for a stronger Plan agent independent of core OpenCode priorities.
20+
21+
1122
## Install for OpenCode
1223

13-
Add this to `opencode.json`:
24+
Add this to `opencode.jsonc` (or `opencode.json`):
1425

1526
```json
1627
{
@@ -40,9 +51,10 @@ If you want reproducible installs instead of automatic plugin refreshes, pin an
4051
- denies `submit_plan`, `edit_plan`, and `plan_exit` to the built-in `general` and `explore` subagents so review and implementation handoff stay on the primary `plan` agent
4152
- exposes a `plan_prompt` tool so the `plan` agent can reveal the plugin's prompt basis for customization
4253
- exposes an `edit_plan` tool so the `plan` agent can open the current plan in the configured external editor
54+
- registers an `/edit-plan` command that routes to the `plan` agent and asks it to call `edit_plan`
4355
- uses `submit_plan` for review when available, otherwise falls back to external-editor review
4456
- keeps the agent in planner mode if the plan file changed after `submit_plan`; the revised plan must be resubmitted before `plan_exit`
45-
- can leave planner mode with `plan_exit` after approval when experimental plan mode is enabled in the CLI runtime
57+
- can leave planner mode with `plan_exit` after approval when experimental plan mode is enabled in the CLI runtime (because the plan_exit tool is only available with `EXPERIMENTAL_PLAN_MODE` enabled; se OpenCode docs for Experiments)
4658

4759
## Customize the plan agent
4860

@@ -80,6 +92,16 @@ The tool returns:
8092

8193
## Review Without Plannotator
8294

95+
In the TUI, you can use `/edit-plan` as a shortcut to ask the `plan` agent to reopen the current plan in your configured external editor. This routes through the existing `edit_plan` tool behavior.
96+
97+
Example:
98+
99+
```text
100+
/edit-plan
101+
```
102+
103+
This expects the current session to already have a plan file, and it still requires `PLAN_VISUAL`, `VISUAL`, or `EDITOR` to launch a blocking editor command.
104+
83105
If `submit_plan` is not registered by the runtime, the plugin's `edit_plan` tool gives the `plan` agent a fallback way to open the current plan in your configured external editor.
84106

85107
Example:
@@ -126,10 +148,7 @@ If you edit the plan after calling `submit_plan`, the plugin treats that as a ne
126148

127149
OpenCode installs and updates npm plugins automatically. `opencode-planner` tracks `@latest` by default, which is the recommended channel for most users.
128150

129-
The `beta` dist-tag currently points at the same release as `latest`. That keeps existing beta installs on the current stable build until a future prerelease line resumes.
130-
131151
- `@latest`: pick up stable plugin versions on restart
132-
- `@beta`: currently follows the same version as `@latest`
133152
- exact version pin: stay fixed until the config is changed deliberately
134153

135154
If OpenCode appears to keep an older cached plugin, clear the cache under `~/.cache/opencode/` and restart.
@@ -162,8 +181,8 @@ It starts OpenCode with an isolated temporary home/config, keeps the local repo
162181
1. Update `CHANGELOG.md`.
163182
2. Bump the version in `package.json`.
164183
3. Commit the release.
165-
4. Create and push a git tag like `v0.1.1-beta.1` for prereleases or `v0.1.1` for stable releases.
166-
5. Let GitHub Actions publish to npm using the correct dist-tag.
184+
4. Create and push a git tag like `v0.2.0` for the release.
185+
5. Let GitHub Actions publish to npm `latest`.
167186
6. Publish matching GitHub release notes.
168187

169188
The repository includes GitHub Actions templates for CI and npm publishing from version tags.
@@ -179,7 +198,7 @@ Configure npm Trusted Publishing for this package:
179198
- Repository: `opencode-planner`
180199
- Workflow filename: `release.yml`
181200

182-
The release workflow publishes prerelease tags like `v0.1.1-beta.1` to the npm `beta` dist-tag, stable tags like `v0.1.1` to `latest`, and creates matching GitHub release notes automatically.
201+
The release workflow publishes stable tags like `v0.2.0` to npm `latest` and creates matching GitHub release notes automatically.
183202

184203
Trusted Publishing uses GitHub OIDC and does not require an `NPM_TOKEN` secret for publishing.
185204

index.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import process from "node:process"
77
const agent = "plan"
88
const root = ".opencode/plans"
99
const defaultPlanTarget = file("<session-id>")
10+
const editPlanCommand = {
11+
description: "Reopen the current plan in your editor",
12+
agent,
13+
template:
14+
"Reopen the current markdown plan in the configured external editor by calling the edit_plan tool. If the tool reports that the user changed the plan externally, treat those edits as review feedback, summarize what changed, and continue planning from the updated plan.",
15+
}
1016

1117
function truthy(key) {
1218
const value = process.env[key]?.toLowerCase()
@@ -60,7 +66,7 @@ function promptDisclosure(target = defaultPlanTarget) {
6066
"This reminder is injected by the plugin at runtime to keep the `plan` agent in planner mode and enforce the review handoff workflow. It is plugin-controlled and is not customized through `agent.plan.prompt`.",
6167
note(target.replace(`${root}/`, "").replace(/\.md$/, "")),
6268
"## How to customize it",
63-
"Only the Base prompt above is replaced by `agent.plan.prompt`. Add this to `opencode.json` to replace that base prompt:",
69+
"Only the Base prompt above is replaced by `agent.plan.prompt`. Add this to `opencode.jsonc` to replace that base prompt:",
6470
[
6571
"```json",
6672
"{",
@@ -353,6 +359,10 @@ export default async function plannerPlugin() {
353359
},
354360
async config(cfg) {
355361
cfg.agent ??= {}
362+
cfg.command = {
363+
"edit-plan": editPlanCommand,
364+
...cfg.command,
365+
}
356366
cfg.agent[agent] = mode(cfg.agent[agent])
357367
cfg.agent.general = restrictPlannerSubagent(cfg.agent.general)
358368
cfg.agent.explore = restrictPlannerSubagent(cfg.agent.explore)

scripts/debug-plan-runtime.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ const config = runJSON(["debug", "config"])
3636
const plan = runJSON(["debug", "agent", "plan"])
3737

3838
const plugins = config.plugin ?? []
39+
const commands = config.command ?? {}
3940
const permissions = plan.permission ?? []
4041
const tools = plan.tools ?? {}
4142
const prompt = plan.prompt ?? ""
4243

44+
const editPlanCommandConfigured = Boolean(commands["edit-plan"])
4345
const planPromptAllowed = hasAllowedPermission("plan_prompt", permissions)
4446
const editPlanAllowed = hasAllowedPermission("edit_plan", permissions)
4547
const planExitAllowed = hasAllowedPermission("plan_exit", permissions)
@@ -54,6 +56,7 @@ const promptMentionsPlanExit = prompt.includes("plan_exit")
5456
console.log("OpenCode plan runtime check")
5557
console.log("")
5658
line("Repo plugin loaded", usingLocalPlugin ? "yes" : "no")
59+
line("/edit-plan command", editPlanCommandConfigured ? "yes" : "no")
5760
line("plan_prompt allowed", planPromptAllowed ? "yes" : "no")
5861
line("plan_prompt tool", planPromptTool ? "yes" : "no")
5962
line("edit_plan allowed", editPlanAllowed ? "yes" : "no")
@@ -78,6 +81,9 @@ console.log("\nAssessment:")
7881
if (!usingLocalPlugin) {
7982
console.log(`- OpenCode is not using the local repo plugin at ${localPlugin}.`)
8083
}
84+
if (!editPlanCommandConfigured) {
85+
console.log("- /edit-plan is not configured in the resolved command list.")
86+
}
8187
if (!planPromptTool) {
8288
console.log("- plan_prompt is not registered as a runtime tool.")
8389
}

scripts/run-opencode-sandbox.js

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { fileURLToPath } from "node:url"
88
const here = path.dirname(fileURLToPath(import.meta.url))
99
const root = path.resolve(here, "..")
1010
const localPlanner = `file://${path.join(root, "index.js")}`
11-
const globalConfigPath = path.join(os.homedir(), ".config", "opencode", "opencode.json")
11+
const globalConfigDir = path.join(os.homedir(), ".config", "opencode")
12+
const globalConfigPaths = [
13+
path.join(globalConfigDir, "opencode.jsonc"),
14+
path.join(globalConfigDir, "opencode.json"),
15+
]
1216

1317
function usage() {
1418
console.log(`Usage: node scripts/run-opencode-sandbox.js [--without-plannotator] [opencode args...]
@@ -24,18 +28,82 @@ function normalizePlugin(entry) {
2428
}
2529

2630
function parseLooseJSON(text) {
27-
return JSON.parse(
28-
text
29-
.replace(/^\uFEFF/, "")
30-
.replace(/,\s*([}\]])/g, "$1"),
31-
)
31+
const source = text.replace(/^\uFEFF/, "")
32+
let out = ""
33+
let inString = false
34+
let quote = ""
35+
let escaped = false
36+
37+
for (let i = 0; i < source.length; i++) {
38+
const char = source[i]
39+
const next = source[i + 1]
40+
41+
if (inString) {
42+
out += char
43+
if (escaped) {
44+
escaped = false
45+
continue
46+
}
47+
if (char === "\\") {
48+
escaped = true
49+
continue
50+
}
51+
if (char === quote) {
52+
inString = false
53+
quote = ""
54+
}
55+
continue
56+
}
57+
58+
if (char === '"') {
59+
inString = true
60+
quote = char
61+
out += char
62+
continue
63+
}
64+
65+
if (char === "/" && next === "/") {
66+
i += 2
67+
while (i < source.length && source[i] !== "\n") i += 1
68+
if (i < source.length) out += "\n"
69+
continue
70+
}
71+
72+
if (char === "/" && next === "*") {
73+
i += 2
74+
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i += 1
75+
i += 1
76+
continue
77+
}
78+
79+
out += char
80+
}
81+
82+
return JSON.parse(out.replace(/,\s*([}\]])/g, "$1"))
3283
}
3384

3485
function keepWithoutPlannotator(entry) {
3586
const id = normalizePlugin(entry)
3687
return id !== "@plannotator/opencode@latest" && id !== "@plannotator/opencode"
3788
}
3889

90+
async function readGlobalConfig() {
91+
for (const file of globalConfigPaths) {
92+
try {
93+
return parseLooseJSON(await fs.readFile(file, "utf8"))
94+
} catch (error) {
95+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
96+
continue
97+
}
98+
throw error
99+
}
100+
}
101+
102+
throw new Error(
103+
`No OpenCode config found. Checked: ${globalConfigPaths.map((file) => `\`${file}\``).join(", ")}`,
104+
)
105+
}
106+
39107
const rawArgs = process.argv.slice(2)
40108
if (rawArgs.includes("-h") || rawArgs.includes("--help")) {
41109
usage()
@@ -45,7 +113,7 @@ if (rawArgs.includes("-h") || rawArgs.includes("--help")) {
45113
const stripPlannotator = rawArgs.includes("--without-plannotator")
46114
const opencodeArgs = rawArgs.filter((arg) => arg !== "--without-plannotator")
47115

48-
const baseConfig = parseLooseJSON(await fs.readFile(globalConfigPath, "utf8"))
116+
const baseConfig = await readGlobalConfig()
49117
const plugin = (baseConfig.plugin ?? []).filter((entry) => !stripPlannotator || keepWithoutPlannotator(entry))
50118

51119
const filtered = plugin.filter((entry) => normalizePlugin(entry) !== localPlanner)

test/plugin.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ test("config hook registers the plan agent without plan_exit by default", async
5959
assert.equal(cfg.agent.plan.permission.plan_prompt, "allow")
6060
assert.equal(cfg.agent.plan.permission.submit_plan, "allow")
6161
assert.equal(cfg.agent.plan.permission.plan_exit, undefined)
62+
assert.equal(cfg.command["edit-plan"].description, "Reopen the current plan in your editor")
63+
assert.equal(cfg.command["edit-plan"].agent, "plan")
64+
assert.match(cfg.command["edit-plan"].template, /calling the edit_plan tool/i)
6265
assert.match(cfg.agent.plan.prompt, /if the submit_plan tool is available/i)
6366
assert.match(cfg.agent.plan.prompt, /call edit_plan to open the markdown plan/i)
6467
assert.match(cfg.agent.plan.prompt, /treat that as review feedback on the plan/i)
@@ -68,6 +71,29 @@ test("config hook registers the plan agent without plan_exit by default", async
6871
)
6972
})
7073

74+
test("config hook adds edit-plan without overwriting user commands", async () => {
75+
const plugin = await plannerPlugin()
76+
const cfg = {
77+
command: {
78+
custom: {
79+
template: "Do something custom.",
80+
},
81+
"edit-plan": {
82+
template: "Use my custom edit-plan flow.",
83+
description: "Custom edit plan",
84+
agent: "general",
85+
},
86+
},
87+
}
88+
89+
await plugin.config(cfg)
90+
91+
assert.equal(cfg.command.custom.template, "Do something custom.")
92+
assert.equal(cfg.command["edit-plan"].template, "Use my custom edit-plan flow.")
93+
assert.equal(cfg.command["edit-plan"].description, "Custom edit plan")
94+
assert.equal(cfg.command["edit-plan"].agent, "general")
95+
})
96+
7197
test("config hook lets users replace the plugin prompt", async () => {
7298
await withEnv(
7399
{

0 commit comments

Comments
 (0)