Skip to content

Commit c087a57

Browse files
committed
feat: scaffold opencode planner plugin
0 parents  commit c087a57

10 files changed

Lines changed: 389 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-node@v4
15+
with:
16+
node-version: 22
17+
cache: npm
18+
- run: npm ci
19+
- run: npm test

.github/workflows/release.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
id-token: write
11+
12+
jobs:
13+
publish:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 22
20+
registry-url: https://registry.npmjs.org
21+
cache: npm
22+
- run: npm ci
23+
- run: npm test
24+
- run: npm publish --provenance
25+
env:
26+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
27+
- uses: softprops/action-gh-release@v2
28+
with:
29+
generate_release_notes: true

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
npm-debug.log*
3+
*.tgz

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Changelog
2+
3+
## 0.1.0
4+
5+
- initial standalone npm package for the `plan` agent plugin
6+
- includes OpenCode install instructions, CI, and npm publish workflow templates

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Tim Richardson
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# opencode-planner
2+
3+
`opencode-planner` is an OpenCode plugin that adds a dedicated `plan` agent for read-only planning before implementation.
4+
5+
Repository: <https://github.com/timrichardson/opencode-planner>
6+
7+
## Install for OpenCode
8+
9+
Add this to `opencode.json`:
10+
11+
```json
12+
{
13+
"plugin": ["opencode-planner@latest"]
14+
}
15+
```
16+
17+
Then restart OpenCode.
18+
19+
If you want reproducible installs instead of automatic plugin refreshes, pin an exact version:
20+
21+
```json
22+
{
23+
"plugin": ["opencode-planner@0.1.0"]
24+
}
25+
```
26+
27+
## What it does
28+
29+
- adds a `plan` agent intended for design and implementation planning
30+
- constrains that agent to read-only tools plus markdown plan editing
31+
- injects a system reminder that keeps the planning workflow explicit
32+
33+
## Auto-updates
34+
35+
OpenCode installs npm plugins automatically. Using `opencode-planner@latest` gives the smoothest update path for most users.
36+
37+
- `@latest`: pick up new published plugin versions on restart
38+
- exact version pin: stay fixed until the config is changed deliberately
39+
40+
If OpenCode appears to keep an older cached plugin, clear the cache under `~/.cache/opencode/` and restart.
41+
42+
## Development
43+
44+
```bash
45+
npm test
46+
```
47+
48+
## Release process
49+
50+
1. Update `CHANGELOG.md`.
51+
2. Bump the version in `package.json`.
52+
3. Commit the release.
53+
4. Create and push a git tag like `v0.1.1`.
54+
5. Publish to npm.
55+
6. Publish matching GitHub release notes.
56+
57+
The repository includes GitHub Actions templates for CI and npm publishing from version tags.
58+
59+
## GitHub Actions setup
60+
61+
Set this repository secret for automated npm publishing:
62+
63+
- `NPM_TOKEN`
64+
65+
The release workflow publishes on version tags like `v0.1.0` and creates GitHub release notes automatically.
66+
67+
## License
68+
69+
MIT

index.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import path from "path"
2+
3+
const agent = "plan"
4+
const root = ".opencode/plans"
5+
6+
function file(id) {
7+
return path.posix.join(root, `${id}.md`)
8+
}
9+
10+
function note(id) {
11+
return [
12+
"<system-reminder>",
13+
"Planner mode is active.",
14+
"You must not edit source files, run bash, change config, or make commits.",
15+
"You may only use read-only tools, ask clarifying questions, delegate exploration or design with the task tool, and edit allowed markdown plan files.",
16+
`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.",
18+
"</system-reminder>",
19+
].join("\n")
20+
}
21+
22+
function partID() {
23+
return `prt_${crypto.randomUUID()}`
24+
}
25+
26+
function rules(input) {
27+
if (!input) return {}
28+
if (typeof input === "string") return { "*": input }
29+
return input
30+
}
31+
32+
function merge(a, b) {
33+
const left = rules(a)
34+
const right = rules(b)
35+
const out = { ...left, ...right }
36+
37+
for (const key of Object.keys(left)) {
38+
const x = left[key]
39+
const y = right[key]
40+
if (!x || !y || typeof x !== "object" || typeof y !== "object") continue
41+
if (Array.isArray(x) || Array.isArray(y)) continue
42+
out[key] = { ...x, ...y }
43+
}
44+
45+
return out
46+
}
47+
48+
function mode(input = {}) {
49+
const base = {
50+
mode: "primary",
51+
color: "info",
52+
description: "Researches the codebase and writes execution plans without editing source files.",
53+
prompt: [
54+
"Use this agent when the user wants a design, implementation plan, or scoped investigation before coding.",
55+
"Stay in planning mode: inspect the codebase, ask targeted questions when needed, and write a concise execution plan before implementation.",
56+
"Default plan path: .opencode/plans/<session-id>.md.",
57+
"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.",
59+
].join("\n\n"),
60+
permission: {
61+
"*": "deny",
62+
read: {
63+
"*": "allow",
64+
"*.env": "ask",
65+
"*.env.*": "ask",
66+
"*.env.example": "allow",
67+
},
68+
glob: "allow",
69+
grep: "allow",
70+
question: "allow",
71+
task: {
72+
"*": "deny",
73+
explore: "allow",
74+
general: "allow",
75+
},
76+
webfetch: "allow",
77+
websearch: "allow",
78+
codesearch: "allow",
79+
batch: "allow",
80+
submit_plan: "allow",
81+
edit: {
82+
"*": "deny",
83+
[path.posix.join(root, "*.md")]: "allow",
84+
"plans/*.md": "allow",
85+
"specs/*.md": "allow",
86+
"**/*.plan.md": "allow",
87+
"**/*.spec.md": "allow",
88+
},
89+
bash: "deny",
90+
skill: "deny",
91+
todowrite: "deny",
92+
},
93+
}
94+
95+
return {
96+
...base,
97+
...input,
98+
prompt: [base.prompt, input?.prompt].filter(Boolean).join("\n\n"),
99+
permission: merge(base.permission, input?.permission),
100+
}
101+
}
102+
103+
export default async function plannerPlugin() {
104+
const seen = new Set()
105+
106+
return {
107+
async config(cfg) {
108+
cfg.agent ??= {}
109+
cfg.agent[agent] = mode(cfg.agent[agent])
110+
},
111+
async "chat.message"(input, output) {
112+
if (input.agent !== agent) {
113+
seen.delete(input.sessionID)
114+
return
115+
}
116+
117+
seen.add(input.sessionID)
118+
output.parts.push({
119+
id: partID(),
120+
messageID: output.message.id,
121+
sessionID: output.message.sessionID,
122+
type: "text",
123+
text: note(input.sessionID),
124+
synthetic: true,
125+
})
126+
},
127+
async "experimental.chat.system.transform"(input, output) {
128+
if (!input.sessionID || !seen.has(input.sessionID)) return
129+
output.system.push(note(input.sessionID))
130+
},
131+
}
132+
}

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "opencode-planner",
3+
"version": "0.1.0",
4+
"description": "OpenCode plugin that adds a dedicated planning agent with read-only planning constraints.",
5+
"type": "module",
6+
"author": "Tim Richardson",
7+
"exports": {
8+
".": "./index.js"
9+
},
10+
"files": [
11+
"index.js",
12+
"README.md",
13+
"LICENSE"
14+
],
15+
"scripts": {
16+
"test": "node --test"
17+
},
18+
"keywords": [
19+
"opencode",
20+
"plugin",
21+
"planning",
22+
"agent"
23+
],
24+
"repository": {
25+
"type": "git",
26+
"url": "git+https://github.com/timrichardson/opencode-planner.git"
27+
},
28+
"homepage": "https://github.com/timrichardson/opencode-planner#readme",
29+
"bugs": {
30+
"url": "https://github.com/timrichardson/opencode-planner/issues"
31+
},
32+
"license": "MIT"
33+
}

test/plugin.test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
4+
import plannerPlugin from "../index.js"
5+
6+
test("config hook registers the plan agent", async () => {
7+
const plugin = await plannerPlugin()
8+
const cfg = {}
9+
10+
await plugin.config(cfg)
11+
12+
assert.equal(cfg.agent.plan.mode, "primary")
13+
assert.equal(cfg.agent.plan.permission.bash, "deny")
14+
assert.equal(cfg.agent.plan.permission.submit_plan, "allow")
15+
})
16+
17+
test("chat.message injects a planner reminder part", async () => {
18+
const plugin = await plannerPlugin()
19+
const input = {
20+
agent: "plan",
21+
sessionID: "ses_123",
22+
}
23+
const output = {
24+
message: {
25+
id: "msg_123",
26+
sessionID: "ses_123",
27+
},
28+
parts: [],
29+
}
30+
31+
await plugin["chat.message"](input, output)
32+
33+
assert.equal(output.parts.length, 1)
34+
assert.equal(output.parts[0].type, "text")
35+
assert.match(output.parts[0].id, /^prt_/)
36+
assert.match(output.parts[0].text, /Planner mode is active\./)
37+
})
38+
39+
test("system transform only applies after planner messages", async () => {
40+
const plugin = await plannerPlugin()
41+
const system = { system: [] }
42+
43+
await plugin["experimental.chat.system.transform"]({ sessionID: "ses_other" }, system)
44+
assert.deepEqual(system.system, [])
45+
46+
await plugin["chat.message"](
47+
{
48+
agent: "plan",
49+
sessionID: "ses_plan",
50+
},
51+
{
52+
message: {
53+
id: "msg_plan",
54+
sessionID: "ses_plan",
55+
},
56+
parts: [],
57+
},
58+
)
59+
60+
await plugin["experimental.chat.system.transform"]({ sessionID: "ses_plan" }, system)
61+
62+
assert.equal(system.system.length, 1)
63+
assert.match(system.system[0], /submit_plan/)
64+
})

0 commit comments

Comments
 (0)