open-plan-annotator 1.4.1 → 1.5.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -0
- package/opencode/bridge.js +12 -186
- package/package.json +21 -10
- package/shared/piExtension.mjs +134 -0
- package/shared/planReview.mjs +203 -0
- package/shared/updateMessage.mjs +2 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Interactive plan annotation plugin for Claude Code",
|
|
8
|
-
"version": "1.
|
|
8
|
+
"version": "1.5.0"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
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": "1.
|
|
15
|
+
"version": "1.5.0",
|
|
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": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "ndom91"
|
|
7
7
|
},
|
package/README.md
CHANGED
|
@@ -61,6 +61,16 @@ To update, refresh the plugin through OpenCode and restart the app so it reloads
|
|
|
61
61
|
> [!NOTE]
|
|
62
62
|
> The update mechanism changed significantly in `1.0.20+`: OpenCode now loads the npm package plus a platform runtime package instead of using the old in-place binary updater. If OpenCode appears to be stuck on an older plugin build, clear the cached `open-plan-annotator` entries under `~/.cache/opencode/node_modules/` and restart OpenCode.
|
|
63
63
|
|
|
64
|
+
### Pi
|
|
65
|
+
|
|
66
|
+
Install the dedicated Pi package:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
pi install npm:@open-plan-annotator/pi-extension
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The extension package registers a `submit_plan` tool and an `/annotate-plan` command, both of which open the same browser-based plan review UI.
|
|
73
|
+
|
|
64
74
|
#### Implementation Handoff
|
|
65
75
|
|
|
66
76
|
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/`:
|
package/opencode/bridge.js
CHANGED
|
@@ -1,201 +1,27 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { existsSync, statSync } from "node:fs";
|
|
4
|
-
import { dirname } from "node:path";
|
|
5
1
|
import { fileURLToPath } from "node:url";
|
|
6
2
|
import { detectPackageManager } from "../shared/packageManager.mjs";
|
|
3
|
+
import { runPlanReviewBinary } from "../shared/planReview.mjs";
|
|
7
4
|
import { resolveRuntimeBinary } from "../shared/runtimeResolver.mjs";
|
|
8
5
|
|
|
9
|
-
const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @typedef {{
|
|
13
|
-
* hookSpecificOutput: {
|
|
14
|
-
* hookEventName: "PermissionRequest",
|
|
15
|
-
* decision: { behavior: "allow" } | { behavior: "deny", message: string }
|
|
16
|
-
* }
|
|
17
|
-
* }} HookOutput
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @param {{ plan: string, sessionId?: string, cwd?: string }} options
|
|
22
|
-
*/
|
|
23
|
-
function buildHookPayload(options) {
|
|
24
|
-
return {
|
|
25
|
-
session_id: options.sessionId ?? randomUUID(),
|
|
26
|
-
transcript_path: "",
|
|
27
|
-
cwd: options.cwd ?? process.cwd(),
|
|
28
|
-
permission_mode: "default",
|
|
29
|
-
hook_event_name: "PermissionRequest",
|
|
30
|
-
tool_name: "ExitPlanMode",
|
|
31
|
-
tool_use_id: randomUUID(),
|
|
32
|
-
tool_input: {
|
|
33
|
-
plan: options.plan,
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @param {string} stdoutText
|
|
40
|
-
* @param {string} stderrText
|
|
41
|
-
* @returns {HookOutput}
|
|
42
|
-
*/
|
|
43
|
-
function parseHookOutput(stdoutText, stderrText) {
|
|
44
|
-
const trimmed = stdoutText.trim();
|
|
45
|
-
if (!trimmed) {
|
|
46
|
-
const stderr = stderrText.trim();
|
|
47
|
-
throw new Error(
|
|
48
|
-
stderr
|
|
49
|
-
? `open-plan-annotator returned empty stdout; stderr: ${stderr}`
|
|
50
|
-
: "open-plan-annotator returned empty stdout",
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
return validateHookOutput(JSON.parse(trimmed));
|
|
56
|
-
} catch {
|
|
57
|
-
const lines = trimmed
|
|
58
|
-
.split(/\r?\n/)
|
|
59
|
-
.map((line) => line.trim())
|
|
60
|
-
.filter(Boolean)
|
|
61
|
-
.reverse();
|
|
62
|
-
|
|
63
|
-
for (const line of lines) {
|
|
64
|
-
try {
|
|
65
|
-
return validateHookOutput(JSON.parse(line));
|
|
66
|
-
} catch {
|
|
67
|
-
// ignore and keep searching
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
throw new Error("open-plan-annotator returned invalid hook JSON");
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* @param {unknown} value
|
|
77
|
-
* @returns {HookOutput}
|
|
78
|
-
*/
|
|
79
|
-
function validateHookOutput(value) {
|
|
80
|
-
if (!value || typeof value !== "object") {
|
|
81
|
-
throw new Error("invalid hook output shape");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const output = /** @type {HookOutput} */ (value);
|
|
85
|
-
const decision = output?.hookSpecificOutput?.decision;
|
|
86
|
-
|
|
87
|
-
if (!decision || typeof decision !== "object" || typeof decision.behavior !== "string") {
|
|
88
|
-
throw new Error("missing decision in hook output");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (decision.behavior === "allow") {
|
|
92
|
-
return output;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (decision.behavior === "deny" && typeof decision.message === "string") {
|
|
96
|
-
return output;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
throw new Error("unsupported decision payload");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
6
|
/**
|
|
103
7
|
* @param {{ plan: string, sessionId?: string, cwd?: string }} options
|
|
104
8
|
*/
|
|
105
9
|
export async function runPlanReview(options) {
|
|
106
10
|
const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
|
|
107
11
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
} catch {
|
|
119
|
-
cwd = PKG_ROOT;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Spawn detached so the binary can outlive this call — it keeps its
|
|
123
|
-
// HTTP server alive for ~10s after emitting the JSON hook response.
|
|
124
|
-
const child = spawn(runtime.binaryPath, [], {
|
|
125
|
-
cwd,
|
|
126
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
127
|
-
env: {
|
|
128
|
-
...process.env,
|
|
129
|
-
OPEN_PLAN_HOST: "opencode",
|
|
130
|
-
OPEN_PLAN_PKG_MANAGER:
|
|
131
|
-
process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
|
|
132
|
-
},
|
|
133
|
-
detached: true,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
let stdout = "";
|
|
137
|
-
let stderr = "";
|
|
138
|
-
let resolved = false;
|
|
139
|
-
|
|
140
|
-
child.stdout.on("data", (chunk) => {
|
|
141
|
-
stdout += String(chunk);
|
|
142
|
-
if (resolved) return;
|
|
143
|
-
|
|
144
|
-
// Scan for a complete JSON hook-output line. Once found, resolve
|
|
145
|
-
// immediately and let the binary keep running in the background.
|
|
146
|
-
const lines = stdout.split("\n");
|
|
147
|
-
for (const line of lines) {
|
|
148
|
-
const trimmed = line.trim();
|
|
149
|
-
if (!trimmed) continue;
|
|
150
|
-
try {
|
|
151
|
-
const parsed = validateHookOutput(JSON.parse(trimmed));
|
|
152
|
-
resolved = true;
|
|
153
|
-
child.unref();
|
|
154
|
-
resolve(parsed);
|
|
155
|
-
return;
|
|
156
|
-
} catch {
|
|
157
|
-
// Not valid hook JSON yet, keep buffering
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
child.stderr.on("data", (chunk) => {
|
|
163
|
-
stderr += String(chunk);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
child.on("error", (error) => {
|
|
167
|
-
if (!resolved) reject(error);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
child.on("close", (code, signal) => {
|
|
171
|
-
if (resolved) return;
|
|
172
|
-
// Binary exited without producing valid JSON
|
|
173
|
-
if (signal) {
|
|
174
|
-
reject(
|
|
175
|
-
new Error(
|
|
176
|
-
stderr.trim()
|
|
177
|
-
? `open-plan-annotator was terminated by signal ${signal}: ${stderr.trim()}`
|
|
178
|
-
: `open-plan-annotator was terminated by signal ${signal}`,
|
|
179
|
-
),
|
|
180
|
-
);
|
|
181
|
-
} else if (code !== 0) {
|
|
182
|
-
reject(
|
|
183
|
-
new Error(
|
|
184
|
-
stderr.trim()
|
|
185
|
-
? `open-plan-annotator exited with code ${code}: ${stderr.trim()}`
|
|
186
|
-
: `open-plan-annotator exited with code ${code}`,
|
|
187
|
-
),
|
|
188
|
-
);
|
|
189
|
-
} else {
|
|
190
|
-
reject(new Error("open-plan-annotator exited without producing hook output"));
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
195
|
-
child.stdin.end();
|
|
12
|
+
const output = await runPlanReviewBinary({
|
|
13
|
+
binaryPath: runtime.binaryPath,
|
|
14
|
+
plan: options.plan,
|
|
15
|
+
sessionId: options.sessionId,
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
env: {
|
|
18
|
+
OPEN_PLAN_HOST: "opencode",
|
|
19
|
+
OPEN_PLAN_PKG_MANAGER: process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
|
|
20
|
+
},
|
|
21
|
+
detached: true,
|
|
196
22
|
});
|
|
197
23
|
|
|
198
|
-
const decision =
|
|
24
|
+
const decision = output.hookSpecificOutput.decision;
|
|
199
25
|
|
|
200
26
|
if (decision.behavior === "allow") {
|
|
201
27
|
return { approved: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-plan-annotator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Fully local plugin for interactive plan annotation from your Agentic assistants",
|
|
6
6
|
"author": "ndom91",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"shared/runtimeResolver.mjs",
|
|
35
35
|
"shared/updateHints.mjs",
|
|
36
36
|
"shared/versionInfo.mjs",
|
|
37
|
+
"shared/planReview.mjs",
|
|
38
|
+
"shared/piExtension.mjs",
|
|
37
39
|
".claude-plugin/",
|
|
38
40
|
"opencode/bridge.js",
|
|
39
41
|
"opencode/config.js",
|
|
@@ -62,19 +64,28 @@
|
|
|
62
64
|
"postpack": "bun scripts/claude-pack-docs.mjs postpack"
|
|
63
65
|
},
|
|
64
66
|
"devDependencies": {
|
|
65
|
-
"@biomejs/biome": "^2.4.
|
|
66
|
-
"@types/node": "^25.
|
|
67
|
-
"@types/bun": "^1.3.
|
|
68
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
67
|
+
"@biomejs/biome": "^2.4.13",
|
|
68
|
+
"@types/node": "^25.6.0",
|
|
69
|
+
"@types/bun": "^1.3.13",
|
|
70
|
+
"@typescript/native-preview": "^7.0.0-dev.20260430.1",
|
|
71
|
+
"typebox": "^1.1.37"
|
|
69
72
|
},
|
|
70
73
|
"dependencies": {
|
|
71
|
-
"@opencode-ai/plugin": "^1.
|
|
74
|
+
"@opencode-ai/plugin": "^1.14.30"
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"typebox": "*"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"typebox": {
|
|
81
|
+
"optional": true
|
|
82
|
+
}
|
|
72
83
|
},
|
|
73
84
|
"optionalDependencies": {
|
|
74
|
-
"@open-plan-annotator/runtime-darwin-arm64": "1.
|
|
75
|
-
"@open-plan-annotator/runtime-darwin-x64": "1.
|
|
76
|
-
"@open-plan-annotator/runtime-linux-arm64": "1.
|
|
77
|
-
"@open-plan-annotator/runtime-linux-x64": "1.
|
|
85
|
+
"@open-plan-annotator/runtime-darwin-arm64": "1.5.0",
|
|
86
|
+
"@open-plan-annotator/runtime-darwin-x64": "1.5.0",
|
|
87
|
+
"@open-plan-annotator/runtime-linux-arm64": "1.5.0",
|
|
88
|
+
"@open-plan-annotator/runtime-linux-x64": "1.5.0"
|
|
78
89
|
},
|
|
79
90
|
"overrides": {
|
|
80
91
|
"uuid": "^14.0.0"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { detectPackageManager } from "./packageManager.mjs";
|
|
4
|
+
import { runPlanReviewBinary } from "./planReview.mjs";
|
|
5
|
+
import { resolveRuntimeBinary } from "./runtimeResolver.mjs";
|
|
6
|
+
|
|
7
|
+
const TOOL_NAME = "submit_plan";
|
|
8
|
+
|
|
9
|
+
function getSessionId(ctx) {
|
|
10
|
+
try {
|
|
11
|
+
return ctx.sessionManager.getSessionId();
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getLastAssistantText(ctx) {
|
|
18
|
+
const branch = ctx.sessionManager.getBranch();
|
|
19
|
+
|
|
20
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
21
|
+
const entry = branch[i];
|
|
22
|
+
if (entry.type !== "message") continue;
|
|
23
|
+
|
|
24
|
+
const message = entry.message;
|
|
25
|
+
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
26
|
+
|
|
27
|
+
const text = message.content
|
|
28
|
+
.filter((block) => block.type === "text")
|
|
29
|
+
.map((block) => block.text)
|
|
30
|
+
.join("\n")
|
|
31
|
+
.trim();
|
|
32
|
+
|
|
33
|
+
if (text) return text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function reviewPlan(plan, ctx) {
|
|
40
|
+
const runtime = resolveRuntimeBinary({ parentUrl: import.meta.url });
|
|
41
|
+
const result = await runPlanReviewBinary({
|
|
42
|
+
binaryPath: runtime.binaryPath,
|
|
43
|
+
plan,
|
|
44
|
+
sessionId: getSessionId(ctx),
|
|
45
|
+
cwd: ctx.cwd,
|
|
46
|
+
env: {
|
|
47
|
+
OPEN_PLAN_HOST: "pi",
|
|
48
|
+
OPEN_PLAN_PKG_MANAGER:
|
|
49
|
+
process.env.OPEN_PLAN_PKG_MANAGER || detectPackageManager({ installPath: fileURLToPath(import.meta.url) }),
|
|
50
|
+
},
|
|
51
|
+
detached: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const decision = result.hookSpecificOutput.decision;
|
|
55
|
+
return {
|
|
56
|
+
approved: decision.behavior === "allow",
|
|
57
|
+
feedback: decision.behavior === "deny" ? decision.message : undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function registerPiExtension(pi) {
|
|
62
|
+
pi.registerTool({
|
|
63
|
+
name: TOOL_NAME,
|
|
64
|
+
label: "Annotate Plan",
|
|
65
|
+
description: "Open the browser-based plan annotation UI and return approval or revision feedback.",
|
|
66
|
+
promptSnippet: "Use this tool after drafting a plan that needs human review before implementation.",
|
|
67
|
+
promptGuidelines: [
|
|
68
|
+
"Call submit_plan when you have a concrete plan in markdown form.",
|
|
69
|
+
"Include the full plan text, including numbered steps if available.",
|
|
70
|
+
"Wait for the review result before proceeding with implementation.",
|
|
71
|
+
],
|
|
72
|
+
parameters: Type.Object({
|
|
73
|
+
plan: Type.String({ description: "The plan markdown to review" }),
|
|
74
|
+
}),
|
|
75
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
76
|
+
const review = await reviewPlan(params.plan, ctx);
|
|
77
|
+
|
|
78
|
+
if (review.approved) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: "Plan approved. Continue with implementation." }],
|
|
81
|
+
details: { approved: true },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "text",
|
|
89
|
+
text: `Plan changes requested:\n\n${review.feedback ?? "No feedback provided."}`,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
details: { approved: false, feedback: review.feedback ?? null },
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.registerCommand("annotate-plan", {
|
|
98
|
+
description: "Open the plan annotation UI for the latest plan or provided plan text.",
|
|
99
|
+
handler: async (args, ctx) => {
|
|
100
|
+
let plan = args.trim();
|
|
101
|
+
|
|
102
|
+
if (!plan) {
|
|
103
|
+
plan = getLastAssistantText(ctx) ?? "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!plan && !ctx.hasUI) {
|
|
107
|
+
ctx.ui.notify("Usage: /annotate-plan <plan markdown> or run it after a plan message.", "warning");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!plan) {
|
|
112
|
+
const edited = await ctx.ui.editor("Paste plan markdown", "# Plan\n\n1. ");
|
|
113
|
+
if (!edited?.trim()) {
|
|
114
|
+
ctx.ui.notify("Plan review cancelled", "info");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
plan = edited;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const review = await reviewPlan(plan, ctx);
|
|
122
|
+
if (review.approved) {
|
|
123
|
+
ctx.ui.notify("Plan approved.", "success");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ctx.ui.notify(`Changes requested:\n${review.feedback ?? "No feedback provided."}`, "warning");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
ctx.ui.notify(`Plan review failed: ${message}`, "error");
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{
|
|
11
|
+
* hookSpecificOutput: {
|
|
12
|
+
* hookEventName: "PermissionRequest",
|
|
13
|
+
* decision: { behavior: "allow" } | { behavior: "deny", message: string }
|
|
14
|
+
* }
|
|
15
|
+
* }} HookOutput
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ plan: string, sessionId?: string, cwd?: string }} options
|
|
20
|
+
*/
|
|
21
|
+
export function buildHookPayload(options) {
|
|
22
|
+
return {
|
|
23
|
+
session_id: options.sessionId ?? randomUUID(),
|
|
24
|
+
transcript_path: "",
|
|
25
|
+
cwd: options.cwd ?? process.cwd(),
|
|
26
|
+
permission_mode: "default",
|
|
27
|
+
hook_event_name: "PermissionRequest",
|
|
28
|
+
tool_name: "ExitPlanMode",
|
|
29
|
+
tool_use_id: randomUUID(),
|
|
30
|
+
tool_input: {
|
|
31
|
+
plan: options.plan,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {HookOutput}
|
|
39
|
+
*/
|
|
40
|
+
export function validateHookOutput(value) {
|
|
41
|
+
if (!value || typeof value !== "object") {
|
|
42
|
+
throw new Error("invalid hook output shape");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const output = /** @type {HookOutput} */ (value);
|
|
46
|
+
const decision = output?.hookSpecificOutput?.decision;
|
|
47
|
+
|
|
48
|
+
if (!decision || typeof decision !== "object" || typeof decision.behavior !== "string") {
|
|
49
|
+
throw new Error("missing decision in hook output");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (decision.behavior === "allow") {
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (decision.behavior === "deny" && typeof decision.message === "string") {
|
|
57
|
+
return output;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error("unsupported decision payload");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} stdoutText
|
|
65
|
+
* @param {string} stderrText
|
|
66
|
+
* @returns {HookOutput}
|
|
67
|
+
*/
|
|
68
|
+
export function parseHookOutput(stdoutText, stderrText) {
|
|
69
|
+
const trimmed = stdoutText.trim();
|
|
70
|
+
if (!trimmed) {
|
|
71
|
+
const stderr = stderrText.trim();
|
|
72
|
+
throw new Error(
|
|
73
|
+
stderr
|
|
74
|
+
? `open-plan-annotator returned empty stdout; stderr: ${stderr}`
|
|
75
|
+
: "open-plan-annotator returned empty stdout",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
return validateHookOutput(JSON.parse(trimmed));
|
|
81
|
+
} catch {
|
|
82
|
+
const lines = trimmed
|
|
83
|
+
.split(/\r?\n/)
|
|
84
|
+
.map((line) => line.trim())
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.reverse();
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
try {
|
|
90
|
+
return validateHookOutput(JSON.parse(line));
|
|
91
|
+
} catch {
|
|
92
|
+
// keep searching
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new Error("open-plan-annotator returned invalid hook JSON");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string | undefined} cwd
|
|
102
|
+
*/
|
|
103
|
+
function resolveSpawnCwd(cwd) {
|
|
104
|
+
let resolvedCwd = cwd ?? process.cwd();
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
if (existsSync(resolvedCwd) && !statSync(resolvedCwd).isDirectory()) {
|
|
108
|
+
resolvedCwd = dirname(resolvedCwd);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
resolvedCwd = PKG_ROOT;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return resolvedCwd;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {{
|
|
119
|
+
* binaryPath: string,
|
|
120
|
+
* plan: string,
|
|
121
|
+
* sessionId?: string,
|
|
122
|
+
* cwd?: string,
|
|
123
|
+
* env?: Record<string, string | undefined>,
|
|
124
|
+
* detached?: boolean,
|
|
125
|
+
* }} options
|
|
126
|
+
* @returns {Promise<HookOutput>}
|
|
127
|
+
*/
|
|
128
|
+
export async function runPlanReviewBinary(options) {
|
|
129
|
+
const payload = buildHookPayload({ plan: options.plan, sessionId: options.sessionId, cwd: options.cwd });
|
|
130
|
+
const cwd = resolveSpawnCwd(options.cwd);
|
|
131
|
+
|
|
132
|
+
return await new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn(options.binaryPath, [], {
|
|
134
|
+
cwd,
|
|
135
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
136
|
+
env: {
|
|
137
|
+
...process.env,
|
|
138
|
+
...options.env,
|
|
139
|
+
},
|
|
140
|
+
detached: options.detached ?? true,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let stdout = "";
|
|
144
|
+
let stderr = "";
|
|
145
|
+
let resolved = false;
|
|
146
|
+
|
|
147
|
+
child.stdout.on("data", (chunk) => {
|
|
148
|
+
stdout += String(chunk);
|
|
149
|
+
if (resolved) return;
|
|
150
|
+
|
|
151
|
+
const lines = stdout.split("\n");
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (!trimmed) continue;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const parsed = validateHookOutput(JSON.parse(trimmed));
|
|
158
|
+
resolved = true;
|
|
159
|
+
child.unref();
|
|
160
|
+
resolve(parsed);
|
|
161
|
+
return;
|
|
162
|
+
} catch {
|
|
163
|
+
// Not JSON yet
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
child.stderr.on("data", (chunk) => {
|
|
169
|
+
stderr += String(chunk);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
child.on("error", (error) => {
|
|
173
|
+
if (!resolved) reject(error);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
child.on("close", (code, signal) => {
|
|
177
|
+
if (resolved) return;
|
|
178
|
+
|
|
179
|
+
if (signal) {
|
|
180
|
+
reject(
|
|
181
|
+
new Error(
|
|
182
|
+
stderr.trim()
|
|
183
|
+
? `open-plan-annotator was terminated by signal ${signal}: ${stderr.trim()}`
|
|
184
|
+
: `open-plan-annotator was terminated by signal ${signal}`,
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
} else if (code !== 0) {
|
|
188
|
+
reject(
|
|
189
|
+
new Error(
|
|
190
|
+
stderr.trim()
|
|
191
|
+
? `open-plan-annotator exited with code ${code}: ${stderr.trim()}`
|
|
192
|
+
: `open-plan-annotator exited with code ${code}`,
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
reject(new Error("open-plan-annotator exited without producing hook output"));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
201
|
+
child.stdin.end();
|
|
202
|
+
});
|
|
203
|
+
}
|
package/shared/updateMessage.mjs
CHANGED
|
@@ -7,7 +7,8 @@ export async function buildUpdateMessage(options = {}) {
|
|
|
7
7
|
const host = options.host ?? process.env.OPEN_PLAN_HOST;
|
|
8
8
|
|
|
9
9
|
try {
|
|
10
|
-
const
|
|
10
|
+
const fetchVersion = options.fetchLatestVersion ?? fetchLatestVersion;
|
|
11
|
+
const latestVersion = await fetchVersion();
|
|
11
12
|
if (currentVersion && isNewerVersion(currentVersion, latestVersion)) {
|
|
12
13
|
return `latest v${latestVersion}; ${buildUpdateInstructions({ host, packageManager, version: latestVersion })}`;
|
|
13
14
|
}
|