open-plan-annotator 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  "name": "open-plan-annotator",
13
13
  "source": "./",
14
14
  "description": "Interactive plan annotation UI: review, strikethrough, and comment on Claude's plans before approving. Fully local, no external services.",
15
- "version": "0.2.5",
15
+ "version": "0.2.6",
16
16
  "author": {
17
17
  "name": "ndom91"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
3
  "description": "Interactive plan annotation UI: review, strikethrough, and comment on Claude's plans before approving. Fully local, no external services.",
4
- "version": "0.2.5",
4
+ "version": "0.2.6",
5
5
  "author": {
6
6
  "name": "ndom91"
7
7
  },
package/README.md CHANGED
@@ -18,7 +18,7 @@ Select text to <code style="color: purple">strikethrough</code>, <code style="co
18
18
  2. An ephemeral HTTP server starts and opens a React UI in your browser
19
19
  3. You review and annotate the plan
20
20
  4. **Approve** or **Request Changes**
21
- 5. The tool returns host-specific JSON output (Claude hook output or OpenCode plugin output)
21
+ 5. The tool returns structured JSON output back to the host
22
22
 
23
23
  The server shuts down after you decide. Everything runs locally, nothing leaves your machine.
24
24
 
@@ -57,34 +57,35 @@ This registers the `ExitPlanMode` hook that launches the annotation UI.
57
57
 
58
58
  ### OpenCode
59
59
 
60
- The OpenCode plugin uses the `@opencode-ai/plugin` SDK to register a `submit_plan` tool and inject system prompt instructions that tell the agent to use plan mode.
60
+ Add `open-plan-annotator` to the `plugin` array in your OpenCode config (`opencode.json` or `.opencode/config.json`):
61
61
 
62
- **Option A: Install from npm (recommended)**
63
-
64
- ```sh
65
- npm install -g open-plan-annotator
66
- cd /path/to/your/project
67
- open-plan-annotator-install-opencode
62
+ ```json
63
+ {
64
+ "plugin": ["open-plan-annotator"]
65
+ }
68
66
  ```
69
67
 
70
- This installs the plugin to `~/.config/opencode/plugins/open-plan-annotator/`, installs dependencies, and creates a loader shim that OpenCode auto-discovers — no config changes needed.
71
-
72
- **Option B: From source**
73
-
74
- ```sh
75
- git clone https://github.com/ndom91/open-plan-annotator.git
76
- cd open-plan-annotator
77
- bun install
78
- bun run install:opencode-plugin # installs to .opencode/plugins/ in CWD
79
- ```
80
-
81
- The install script creates the auto-discovery shim, so no config changes needed.
82
-
83
- The plugin automatically:
68
+ OpenCode will install the package and load it automatically. The plugin:
84
69
  - Injects plan-mode instructions into the agent's system prompt
85
70
  - Registers a `submit_plan` tool that the agent calls after creating a plan
86
71
  - Spawns the annotation UI in your browser for review
87
72
  - Returns structured feedback to the agent on approval or rejection
73
+ - Optionally hands off to an implementation agent after approval
74
+
75
+ #### Implementation handoff
76
+
77
+ By default, after a plan is approved the plugin sends "Proceed with implementation." to a `build` agent. To customize or disable this, create `open-plan-annotator.json` in your project's `.opencode/` directory or globally in `~/.config/opencode/`:
78
+
79
+ ```json
80
+ {
81
+ "implementationHandoff": {
82
+ "enabled": true,
83
+ "agent": "build"
84
+ }
85
+ }
86
+ ```
87
+
88
+ Set `enabled` to `false` to disable auto-handoff. Project config overrides global config.
88
89
 
89
90
  ### From source (Claude Code)
90
91
 
@@ -0,0 +1,166 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const WRAPPER_PATH = fileURLToPath(new URL("../bin/open-plan-annotator.cjs", import.meta.url));
6
+
7
+ /**
8
+ * @typedef {{
9
+ * hookSpecificOutput: {
10
+ * hookEventName: "PermissionRequest",
11
+ * decision: { behavior: "allow" } | { behavior: "deny", message: string }
12
+ * }
13
+ * }} HookOutput
14
+ */
15
+
16
+ /**
17
+ * @param {{ plan: string, sessionId?: string, cwd?: string }} options
18
+ */
19
+ function buildHookPayload(options) {
20
+ return {
21
+ session_id: options.sessionId ?? randomUUID(),
22
+ transcript_path: "",
23
+ cwd: options.cwd ?? process.cwd(),
24
+ permission_mode: "default",
25
+ hook_event_name: "PermissionRequest",
26
+ tool_name: "ExitPlanMode",
27
+ tool_use_id: randomUUID(),
28
+ tool_input: {
29
+ plan: options.plan,
30
+ },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * @param {string} stdoutText
36
+ * @param {string} stderrText
37
+ * @returns {HookOutput}
38
+ */
39
+ function parseHookOutput(stdoutText, stderrText) {
40
+ const trimmed = stdoutText.trim();
41
+ if (!trimmed) {
42
+ const stderr = stderrText.trim();
43
+ throw new Error(
44
+ stderr
45
+ ? `open-plan-annotator returned empty stdout; stderr: ${stderr}`
46
+ : "open-plan-annotator returned empty stdout",
47
+ );
48
+ }
49
+
50
+ try {
51
+ return validateHookOutput(JSON.parse(trimmed));
52
+ } catch {
53
+ const lines = trimmed
54
+ .split(/\r?\n/)
55
+ .map((line) => line.trim())
56
+ .filter(Boolean)
57
+ .reverse();
58
+
59
+ for (const line of lines) {
60
+ try {
61
+ return validateHookOutput(JSON.parse(line));
62
+ } catch {
63
+ // ignore and keep searching
64
+ }
65
+ }
66
+
67
+ throw new Error("open-plan-annotator returned invalid hook JSON");
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @param {unknown} value
73
+ * @returns {HookOutput}
74
+ */
75
+ function validateHookOutput(value) {
76
+ if (!value || typeof value !== "object") {
77
+ throw new Error("invalid hook output shape");
78
+ }
79
+
80
+ const output = /** @type {HookOutput} */ (value);
81
+ const decision = output?.hookSpecificOutput?.decision;
82
+
83
+ if (!decision || typeof decision !== "object" || typeof decision.behavior !== "string") {
84
+ throw new Error("missing decision in hook output");
85
+ }
86
+
87
+ if (decision.behavior === "allow") {
88
+ return output;
89
+ }
90
+
91
+ if (decision.behavior === "deny" && typeof decision.message === "string") {
92
+ return output;
93
+ }
94
+
95
+ throw new Error("unsupported decision payload");
96
+ }
97
+
98
+ /**
99
+ * @param {{ plan: string, sessionId?: string, cwd?: string }} options
100
+ */
101
+ export async function runPlanReview(options) {
102
+ const payload = buildHookPayload(options);
103
+
104
+ const result = await new Promise((resolve, reject) => {
105
+ const child = spawn(WRAPPER_PATH, [], {
106
+ cwd: options.cwd ?? process.cwd(),
107
+ stdio: ["pipe", "pipe", "pipe"],
108
+ env: process.env,
109
+ });
110
+
111
+ let stdout = "";
112
+ let stderr = "";
113
+
114
+ child.stdout.on("data", (chunk) => {
115
+ stdout += String(chunk);
116
+ });
117
+
118
+ child.stderr.on("data", (chunk) => {
119
+ stderr += String(chunk);
120
+ });
121
+
122
+ child.on("error", (error) => {
123
+ reject(error);
124
+ });
125
+
126
+ child.on("close", (code, signal) => {
127
+ resolve({ code, signal, stdout, stderr });
128
+ });
129
+
130
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
131
+ child.stdin.end();
132
+ });
133
+
134
+ const settled =
135
+ /** @type {{ code: number | null, signal: NodeJS.Signals | null, stdout: string, stderr: string }} */ (result);
136
+
137
+ if (settled.signal) {
138
+ const errorText = settled.stderr.trim();
139
+ throw new Error(
140
+ errorText
141
+ ? `open-plan-annotator was terminated by signal ${settled.signal}: ${errorText}`
142
+ : `open-plan-annotator was terminated by signal ${settled.signal}`,
143
+ );
144
+ }
145
+
146
+ if (settled.code !== 0) {
147
+ const errorText = settled.stderr.trim();
148
+ throw new Error(
149
+ errorText
150
+ ? `open-plan-annotator exited with code ${settled.code}: ${errorText}`
151
+ : `open-plan-annotator exited with code ${settled.code}`,
152
+ );
153
+ }
154
+
155
+ const output = parseHookOutput(settled.stdout, settled.stderr);
156
+ const decision = output.hookSpecificOutput.decision;
157
+
158
+ if (decision.behavior === "allow") {
159
+ return { approved: true };
160
+ }
161
+
162
+ return {
163
+ approved: false,
164
+ feedback: decision.message,
165
+ };
166
+ }
@@ -0,0 +1,79 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const CONFIG_FILE_NAME = "open-plan-annotator.json";
6
+ const DEFAULT_HANDOFF_ENABLED = true;
7
+ const DEFAULT_IMPLEMENTATION_AGENT = "build";
8
+
9
+ function isRecord(value) {
10
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ async function readConfigFile(path) {
14
+ try {
15
+ const raw = await readFile(path, "utf8");
16
+ const parsed = JSON.parse(raw);
17
+ return isRecord(parsed) ? parsed : undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ function resolveConfigBaseDir() {
24
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
25
+ if (xdgConfigHome) {
26
+ return xdgConfigHome;
27
+ }
28
+
29
+ const homeDir = homedir()?.trim();
30
+ if (homeDir) {
31
+ return join(homeDir, ".config");
32
+ }
33
+
34
+ return undefined;
35
+ }
36
+
37
+ function parseImplementationHandoff(config) {
38
+ if (!isRecord(config)) {
39
+ return {};
40
+ }
41
+
42
+ const raw = config.implementationHandoff;
43
+ if (!isRecord(raw)) {
44
+ return {};
45
+ }
46
+
47
+ const enabled = typeof raw.enabled === "boolean" ? raw.enabled : undefined;
48
+
49
+ let agent;
50
+ if (typeof raw.agent === "string") {
51
+ const trimmed = raw.agent.trim();
52
+ if (trimmed) {
53
+ agent = trimmed;
54
+ }
55
+ }
56
+
57
+ return { enabled, agent };
58
+ }
59
+
60
+ export async function resolveImplementationHandoff(directory) {
61
+ const globalConfigDir = resolveConfigBaseDir();
62
+ const globalConfigPath = globalConfigDir ? join(globalConfigDir, "opencode", CONFIG_FILE_NAME) : undefined;
63
+ const projectConfigPath = directory ? join(directory, ".opencode", CONFIG_FILE_NAME) : undefined;
64
+
65
+ const globalConfig = globalConfigPath ? parseImplementationHandoff(await readConfigFile(globalConfigPath)) : {};
66
+ const projectConfig = projectConfigPath ? parseImplementationHandoff(await readConfigFile(projectConfigPath)) : {};
67
+
68
+ const enabled = projectConfig.enabled ?? globalConfig.enabled ?? DEFAULT_HANDOFF_ENABLED;
69
+ const agent = projectConfig.agent ?? globalConfig.agent ?? DEFAULT_IMPLEMENTATION_AGENT;
70
+
71
+ return {
72
+ enabled,
73
+ agent,
74
+ paths: {
75
+ global: globalConfigPath,
76
+ project: projectConfigPath,
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveImplementationHandoff } from "./config.js";
6
+
7
+ function withEnv(key: string, value: string | undefined, fn: () => Promise<void>) {
8
+ const original = process.env[key];
9
+ if (value === undefined) {
10
+ delete process.env[key];
11
+ } else {
12
+ process.env[key] = value;
13
+ }
14
+
15
+ return fn().finally(() => {
16
+ if (original === undefined) {
17
+ delete process.env[key];
18
+ } else {
19
+ process.env[key] = original;
20
+ }
21
+ });
22
+ }
23
+
24
+ describe("resolveImplementationHandoff", () => {
25
+ test("returns defaults when no config files exist", async () => {
26
+ const tempRoot = mkdtempSync(join(tmpdir(), "open-plan-annotator-config-"));
27
+
28
+ try {
29
+ await withEnv("XDG_CONFIG_HOME", join(tempRoot, "xdg"), async () => {
30
+ const result = await resolveImplementationHandoff(join(tempRoot, "project"));
31
+ expect(result.enabled).toBe(true);
32
+ expect(result.agent).toBe("build");
33
+ });
34
+ } finally {
35
+ rmSync(tempRoot, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ test("uses global config when project config is missing", async () => {
40
+ const tempRoot = mkdtempSync(join(tmpdir(), "open-plan-annotator-config-"));
41
+ const xdgHome = join(tempRoot, "xdg");
42
+ const globalConfigDir = join(xdgHome, "opencode");
43
+ mkdirSync(globalConfigDir, { recursive: true });
44
+ writeFileSync(
45
+ join(globalConfigDir, "open-plan-annotator.json"),
46
+ JSON.stringify({ implementationHandoff: { enabled: false, agent: "explore" } }),
47
+ "utf8",
48
+ );
49
+
50
+ try {
51
+ await withEnv("XDG_CONFIG_HOME", xdgHome, async () => {
52
+ const result = await resolveImplementationHandoff(join(tempRoot, "project"));
53
+ expect(result.enabled).toBe(false);
54
+ expect(result.agent).toBe("explore");
55
+ });
56
+ } finally {
57
+ rmSync(tempRoot, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test("project config overrides global config", async () => {
62
+ const tempRoot = mkdtempSync(join(tmpdir(), "open-plan-annotator-config-"));
63
+ const xdgHome = join(tempRoot, "xdg");
64
+ const globalConfigDir = join(xdgHome, "opencode");
65
+ const projectDir = join(tempRoot, "project");
66
+ const projectConfigDir = join(projectDir, ".opencode");
67
+
68
+ mkdirSync(globalConfigDir, { recursive: true });
69
+ mkdirSync(projectConfigDir, { recursive: true });
70
+
71
+ writeFileSync(
72
+ join(globalConfigDir, "open-plan-annotator.json"),
73
+ JSON.stringify({ implementationHandoff: { enabled: true, agent: "explore" } }),
74
+ "utf8",
75
+ );
76
+ writeFileSync(
77
+ join(projectConfigDir, "open-plan-annotator.json"),
78
+ JSON.stringify({ implementationHandoff: { enabled: false, agent: "build" } }),
79
+ "utf8",
80
+ );
81
+
82
+ try {
83
+ await withEnv("XDG_CONFIG_HOME", xdgHome, async () => {
84
+ const result = await resolveImplementationHandoff(projectDir);
85
+ expect(result.enabled).toBe(false);
86
+ expect(result.agent).toBe("build");
87
+ });
88
+ } finally {
89
+ rmSync(tempRoot, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ test("ignores malformed config and falls back", async () => {
94
+ const tempRoot = mkdtempSync(join(tmpdir(), "open-plan-annotator-config-"));
95
+ const xdgHome = join(tempRoot, "xdg");
96
+ const globalConfigDir = join(xdgHome, "opencode");
97
+ mkdirSync(globalConfigDir, { recursive: true });
98
+ writeFileSync(join(globalConfigDir, "open-plan-annotator.json"), "not-json", "utf8");
99
+
100
+ try {
101
+ await withEnv("XDG_CONFIG_HOME", xdgHome, async () => {
102
+ const result = await resolveImplementationHandoff(join(tempRoot, "project"));
103
+ expect(result.enabled).toBe(true);
104
+ expect(result.agent).toBe("build");
105
+ });
106
+ } finally {
107
+ rmSync(tempRoot, { recursive: true, force: true });
108
+ }
109
+ });
110
+ });
@@ -0,0 +1,184 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { runPlanReview } from "./bridge.js";
3
+ import { resolveImplementationHandoff } from "./config.js";
4
+
5
+ const PLAN_REVIEW_INSTRUCTIONS = `## Plan Review Workflow
6
+
7
+ For non-trivial implementation work, create a plan first and call the \`submit_plan\` tool.
8
+ The user will review the plan in a browser and either approve it or request changes.
9
+
10
+ - If approved, proceed with implementation.
11
+ - If changes are requested, revise the plan and call \`submit_plan\` again.
12
+
13
+ Do not begin implementation until the plan is approved.`;
14
+
15
+ const IMPLEMENTATION_PROMPT = "Proceed with implementation.";
16
+
17
+ function getErrorMessage(error) {
18
+ if (error instanceof Error && error.message) {
19
+ return error.message;
20
+ }
21
+ return "unknown error";
22
+ }
23
+
24
+ /** @type {import("@opencode-ai/plugin").Plugin} */
25
+ export const OpenPlanAnnotatorPlugin = async (ctx) => {
26
+ const implementationHandoff = await resolveImplementationHandoff(ctx.directory);
27
+ const implementationAgent = implementationHandoff.enabled ? implementationHandoff.agent : undefined;
28
+
29
+ async function getCurrentUserAgent(sessionID) {
30
+ if (!sessionID) {
31
+ return undefined;
32
+ }
33
+
34
+ const response = await ctx.client.session.messages({
35
+ path: { id: sessionID },
36
+ });
37
+
38
+ const messages = response?.data;
39
+ if (!Array.isArray(messages)) {
40
+ return undefined;
41
+ }
42
+
43
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
44
+ const message = messages[i];
45
+ if (message?.info?.role === "user") {
46
+ return message?.info?.agent;
47
+ }
48
+ }
49
+
50
+ return undefined;
51
+ }
52
+
53
+ async function shouldInjectPlanReviewInstructions(sessionID) {
54
+ if (!sessionID) {
55
+ return true;
56
+ }
57
+
58
+ try {
59
+ const currentAgent = await getCurrentUserAgent(sessionID);
60
+ if (!currentAgent) {
61
+ return true;
62
+ }
63
+
64
+ if (implementationAgent && currentAgent === implementationAgent) {
65
+ return false;
66
+ }
67
+
68
+ const response = await ctx.client.app.agents({
69
+ query: { directory: ctx.directory },
70
+ });
71
+ const agents = response?.data;
72
+ if (!Array.isArray(agents)) {
73
+ return true;
74
+ }
75
+
76
+ const agent = agents.find((candidate) => candidate.name === currentAgent);
77
+ if (agent?.mode === "subagent") {
78
+ return false;
79
+ }
80
+ } catch {
81
+ return true;
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ async function handoffToImplementationAgent(sessionID) {
88
+ if (!implementationAgent) {
89
+ return null;
90
+ }
91
+
92
+ if (!sessionID) {
93
+ return {
94
+ agent: implementationAgent,
95
+ warning: "Could not auto-switch because the current session ID was unavailable.",
96
+ };
97
+ }
98
+
99
+ try {
100
+ await ctx.client.session.prompt({
101
+ path: { id: sessionID },
102
+ body: {
103
+ agent: implementationAgent,
104
+ noReply: true,
105
+ parts: [{ type: "text", text: IMPLEMENTATION_PROMPT }],
106
+ },
107
+ });
108
+
109
+ return { agent: implementationAgent };
110
+ } catch (error) {
111
+ return {
112
+ agent: implementationAgent,
113
+ warning: `Could not auto-switch to \`${implementationAgent}\`: ${getErrorMessage(error)}`,
114
+ };
115
+ }
116
+ }
117
+
118
+ return {
119
+ "experimental.chat.system.transform": async (input, output) => {
120
+ const currentSystem = output.system.join("\n").toLowerCase();
121
+ if (currentSystem.includes("title generator") || currentSystem.includes("generate a concise title")) {
122
+ return;
123
+ }
124
+
125
+ if (!currentSystem.includes("submit_plan")) {
126
+ const shouldInject = await shouldInjectPlanReviewInstructions(input?.sessionID);
127
+ if (shouldInject) {
128
+ output.system.push(PLAN_REVIEW_INSTRUCTIONS);
129
+ }
130
+ }
131
+ },
132
+
133
+ tool: {
134
+ submit_plan: tool({
135
+ description:
136
+ "Submit a markdown plan for interactive user review. Returns approval status or structured revision feedback.",
137
+
138
+ args: {
139
+ plan: tool.schema.string().describe("The complete implementation plan in markdown format"),
140
+ summary: tool.schema.string().optional().describe("Optional one-line plan summary"),
141
+ },
142
+
143
+ async execute(args, context) {
144
+ const result = await runPlanReview({
145
+ plan: args.plan,
146
+ sessionId: context.sessionID,
147
+ });
148
+
149
+ if (result.approved) {
150
+ const lines = ["Plan approved by the user."];
151
+
152
+ if (args.summary) {
153
+ lines.push(`Summary: ${args.summary}`);
154
+ }
155
+
156
+ const handoffResult = await handoffToImplementationAgent(context.sessionID);
157
+ if (handoffResult) {
158
+ if (handoffResult.warning) {
159
+ lines.push(`Auto-switch warning: ${handoffResult.warning}`);
160
+ } else {
161
+ lines.push(`Auto-switched to the \`${handoffResult.agent}\` agent for implementation.`);
162
+ }
163
+ }
164
+
165
+ lines.push("Proceed with implementation.");
166
+ return lines.join("\n\n");
167
+ }
168
+
169
+ return [
170
+ "Plan needs revision.",
171
+ "",
172
+ "## User feedback",
173
+ "",
174
+ result.feedback ?? "Plan changes requested.",
175
+ "",
176
+ "Revise the plan using this feedback, then call `submit_plan` again.",
177
+ ].join("\n");
178
+ },
179
+ }),
180
+ },
181
+ };
182
+ };
183
+
184
+ export default OpenPlanAnnotatorPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-plan-annotator",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Fully local plugin for interactive plan annotation from your Agentic assistants",
6
6
  "author": "ndom91",
@@ -17,20 +17,18 @@
17
17
  "annotation",
18
18
  "code-review"
19
19
  ],
20
+ "main": "opencode/index.js",
20
21
  "bin": {
21
- "open-plan-annotator": "bin/open-plan-annotator.cjs",
22
- "open-plan-annotator-install-opencode": "bin/open-plan-annotator-install-opencode.cjs"
22
+ "open-plan-annotator": "bin/open-plan-annotator.cjs"
23
23
  },
24
24
  "files": [
25
25
  "bin/open-plan-annotator.cjs",
26
- "bin/open-plan-annotator-install-opencode.cjs",
27
26
  "install.cjs",
28
27
  ".claude-plugin/",
29
- "opencode-plugin/",
28
+ "opencode/",
30
29
  "hooks/",
31
30
  "CLAUDE.md",
32
- "README.md",
33
- "scripts/install-opencode-plugin.cjs"
31
+ "README.md"
34
32
  ],
35
33
  "scripts": {
36
34
  "postinstall": "node install.cjs",
@@ -46,7 +44,6 @@
46
44
  "lint": "biome check .",
47
45
  "lint:fix": "biome check --write .",
48
46
  "format": "biome format --write .",
49
- "install:opencode-plugin": "node scripts/install-opencode-plugin.cjs",
50
47
  "do-release": "./scripts/release.sh",
51
48
  "prepack": "mv CLAUDE.md CLAUDE.dev.md.bak && cp CLAUDE.plugin.md CLAUDE.md",
52
49
  "postpack": "mv CLAUDE.dev.md.bak CLAUDE.md"
@@ -56,6 +53,7 @@
56
53
  "@types/bun": "^1.3.9"
57
54
  },
58
55
  "dependencies": {
56
+ "@opencode-ai/plugin": "^0.0.3",
59
57
  "@types/node": "^25.3.0"
60
58
  }
61
59
  }
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- require("../scripts/install-opencode-plugin.cjs");
@@ -1,174 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { type Plugin, tool } from "@opencode-ai/plugin";
5
-
6
- function resolveRunner(): { command: string; args: string[] } {
7
- const moduleDir = dirname(new URL(import.meta.url).pathname);
8
- const repoRoot = join(moduleDir, "..");
9
- const sourceServer = join(repoRoot, "server", "index.ts");
10
- const packageBin = join(repoRoot, "bin", "open-plan-annotator.cjs");
11
- const projectLocal = join(process.cwd(), "node_modules", ".bin", "open-plan-annotator");
12
-
13
- if (existsSync(sourceServer) && process.env.OPEN_PLAN_ANNOTATOR_FORCE_BINARY !== "1") {
14
- return { command: "bun", args: ["run", sourceServer] };
15
- }
16
-
17
- if (existsSync(packageBin)) {
18
- return { command: process.execPath, args: [packageBin] };
19
- }
20
-
21
- if (existsSync(projectLocal)) {
22
- return { command: projectLocal, args: [] };
23
- }
24
-
25
- return { command: "open-plan-annotator", args: [] };
26
- }
27
-
28
- function execAsync(
29
- command: string,
30
- args: string[],
31
- stdinData: string,
32
- ): Promise<{ stdout: string; stderr: string; status: number | null }> {
33
- return new Promise((resolve) => {
34
- const child = execFile(
35
- command,
36
- args,
37
- { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
38
- (error, stdout, stderr) => {
39
- resolve({
40
- stdout: stdout ?? "",
41
- stderr: stderr ?? "",
42
- status: error ? 1 : 0,
43
- });
44
- },
45
- );
46
- if (child.stdin) {
47
- child.stdin.write(stdinData);
48
- child.stdin.end();
49
- }
50
- });
51
- }
52
-
53
- function normalizeOutput(
54
- stdout: string,
55
- stderr: string,
56
- _status: number | null,
57
- ): { ok: boolean; decision: string; feedback?: string; message?: string } {
58
- const text = (stdout ?? "").trim();
59
- if (!text) {
60
- return {
61
- ok: false,
62
- decision: "deny",
63
- feedback: stderr || "open-plan-annotator returned empty output",
64
- message: "Plan changes requested.",
65
- };
66
- }
67
-
68
- try {
69
- const parsed = JSON.parse(text);
70
- if (parsed && typeof parsed === "object") return parsed;
71
- } catch {
72
- // fall through
73
- }
74
-
75
- return {
76
- ok: false,
77
- decision: "deny",
78
- feedback: `Unexpected response from open-plan-annotator: ${text}`,
79
- message: "Plan changes requested.",
80
- };
81
- }
82
-
83
- const SYSTEM_INSTRUCTIONS = `
84
- ## Plan Submission
85
-
86
- When you have completed your plan, you MUST call the \`submit_plan\` tool to submit it for user review.
87
- The user will be able to:
88
- - Review your plan visually in a dedicated UI
89
- - Annotate specific sections with feedback
90
- - Approve the plan to proceed with implementation
91
- - Request changes with detailed feedback
92
-
93
- If your plan is rejected, you will receive the user's annotated feedback. Revise your plan
94
- based on their feedback and call submit_plan again.
95
-
96
- Do NOT proceed with implementation until your plan is approved.
97
-
98
- ### When to Use Plan Mode
99
-
100
- Use plan mode (and submit your plan for review) for any task involving:
101
- - Creating or modifying more than 2 files
102
- - Architectural or structural changes
103
- - Anything the user hasn't explicitly described step-by-step
104
- - Refactoring, migration, or feature additions
105
- - Bug fixes that require investigation
106
-
107
- For truly trivial tasks (fix a typo, rename a single variable, answer a factual question), you may skip plan submission.
108
-
109
- ### Plan Quality Standards
110
-
111
- When writing a plan, include:
112
- - A brief summary of what you understood the task to require
113
- - The specific files you intend to create or modify and why
114
- - Any assumptions you are making
115
- - An explicit question if anything is ambiguous
116
- `;
117
-
118
- export const OpenPlanAnnotatorPlugin: Plugin = async (_ctx) => {
119
- return {
120
- "experimental.chat.system.transform": async (_input, output) => {
121
- output.system.push(SYSTEM_INSTRUCTIONS);
122
- },
123
-
124
- tool: {
125
- submit_plan: tool({
126
- description:
127
- "Submit your completed plan for interactive user review. The user can annotate, approve, or request changes. Call this when you have finished creating your implementation plan.",
128
- args: {
129
- plan: tool.schema.string().describe("The complete implementation plan in markdown format"),
130
- summary: tool.schema
131
- .string()
132
- .optional()
133
- .describe("A brief 1-2 sentence summary of what the plan accomplishes"),
134
- },
135
-
136
- async execute(args) {
137
- const runner = resolveRunner();
138
- const payload = JSON.stringify({
139
- host: "opencode",
140
- command: "submit_plan",
141
- plan: args.plan,
142
- cwd: process.cwd(),
143
- });
144
-
145
- const result = await execAsync(runner.command, runner.args, payload);
146
- const output = normalizeOutput(result.stdout, result.stderr, result.status);
147
-
148
- if (output.ok && output.decision === "approve") {
149
- return [
150
- "Plan approved by the user. You may now proceed with implementation.",
151
- args.summary ? `\nPlan Summary: ${args.summary}` : "",
152
- ].join("");
153
- }
154
-
155
- return [
156
- "Plan needs revision.",
157
- "",
158
- "The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly.",
159
- "",
160
- "## User Feedback",
161
- "",
162
- output.feedback ?? "Plan changes requested.",
163
- "",
164
- "---",
165
- "",
166
- "Please revise your plan based on this feedback and call `submit_plan` again when ready.",
167
- ].join("\n");
168
- },
169
- }),
170
- },
171
- };
172
- };
173
-
174
- export default OpenPlanAnnotatorPlugin;
@@ -1,23 +0,0 @@
1
- {
2
- "name": "open-plan-annotator-opencode",
3
- "version": "0.2.3",
4
- "description": "Open Plan Annotator plugin for OpenCode - interactive plan review with visual annotation",
5
- "author": "ndom91",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/ndom91/open-plan-annotator.git",
10
- "directory": "opencode-plugin"
11
- },
12
- "main": "index.ts",
13
- "files": [
14
- "index.ts",
15
- "package.json"
16
- ],
17
- "dependencies": {
18
- "@opencode-ai/plugin": "^1.0.218"
19
- },
20
- "peerDependencies": {
21
- "bun": ">=1.0.0"
22
- }
23
- }
@@ -1,91 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const fs = require("fs");
4
- const path = require("path");
5
-
6
- function resolveTargetRoot() {
7
- const fromArg = process.argv[2];
8
- if (fromArg && fromArg.trim().length > 0) {
9
- return path.resolve(process.cwd(), fromArg);
10
- }
11
- const configHome = process.env.XDG_CONFIG_HOME || path.join(require("os").homedir(), ".config");
12
- return path.join(configHome, "opencode", "plugins");
13
- }
14
-
15
- function ensureParentDir(dir) {
16
- fs.mkdirSync(dir, { recursive: true });
17
- }
18
-
19
- function installPlugin(sourceDir, destinationDir) {
20
- if (fs.existsSync(destinationDir)) {
21
- fs.rmSync(destinationDir, { recursive: true, force: true });
22
- }
23
-
24
- try {
25
- fs.symlinkSync(sourceDir, destinationDir, "dir");
26
- return "symlink";
27
- } catch {
28
- fs.cpSync(sourceDir, destinationDir, { recursive: true });
29
- return "copy";
30
- }
31
- }
32
-
33
- function main() {
34
- const projectRoot = path.resolve(__dirname, "..");
35
- const sourceDir = path.join(projectRoot, "opencode-plugin");
36
- const targetRoot = resolveTargetRoot();
37
- const destinationDir = path.join(targetRoot, "open-plan-annotator");
38
-
39
- if (!fs.existsSync(sourceDir)) {
40
- console.error(`open-plan-annotator: missing opencode-plugin at ${sourceDir}`);
41
- process.exit(1);
42
- }
43
-
44
- ensureParentDir(targetRoot);
45
- const mode = installPlugin(sourceDir, destinationDir);
46
-
47
- console.log(
48
- `open-plan-annotator: installed OpenCode plugin (${mode}) at ${destinationDir}`,
49
- );
50
-
51
- // Install plugin dependencies using the first available package manager
52
- const { execFileSync } = require("child_process");
53
- const packageManagers = [
54
- { cmd: "bun", args: ["install"] },
55
- { cmd: "pnpm", args: ["install"] },
56
- { cmd: "npm", args: ["install"] },
57
- ];
58
-
59
- let installed = false;
60
- for (const pm of packageManagers) {
61
- try {
62
- execFileSync(pm.cmd, pm.args, { cwd: destinationDir, stdio: "inherit" });
63
- installed = true;
64
- break;
65
- } catch {
66
- // Try the next package manager
67
- }
68
- }
69
-
70
- if (!installed) {
71
- console.log(
72
- "open-plan-annotator: could not install dependencies automatically. Run `npm install` in:",
73
- destinationDir,
74
- );
75
- }
76
-
77
- // Create wrapper shim at plugins/open-plan-annotator.ts for OpenCode auto-discovery
78
- // OpenCode scans plugins/*.{ts,js} but not subdirectories
79
- const shimPath = path.join(targetRoot, "open-plan-annotator.ts");
80
- fs.writeFileSync(
81
- shimPath,
82
- `export { default } from "./open-plan-annotator/index.ts";\n`,
83
- );
84
- console.log(`open-plan-annotator: created loader shim at ${shimPath}`);
85
-
86
- console.log(
87
- "\nThe plugin will be auto-discovered by OpenCode — no config changes needed.",
88
- );
89
- }
90
-
91
- main();