Skip to content

Commit b2eae46

Browse files
feat(review): add configurable approval prompts (#561)
* feat(review): add configurable approval prompts Let users override the agent message Plannotator sends after approving a code review. Keep the existing behavior by default while supporting runtime-specific overrides in ~/.plannotator/config.json. * fix(shared): export prompts helper Expose the new shared prompts module through @plannotator/shared so Bun can resolve it during hook builds and CI. * fix: add Gemini CLI to agent origin detection chain Gemini CLI sets GEMINI_CLI=1 in the environment. Add it to the detectedOrigin chain so runtime-specific prompt overrides work on all paths (review, annotate, plan), not just plan review. For provenance purposes, this commit was AI assisted. --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent 93c8440 commit b2eae46

10 files changed

Lines changed: 234 additions & 9 deletions

File tree

apps/hook/server/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannot
7676
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
7777
import { statSync, rmSync, realpathSync, existsSync } from "fs";
7878
import { parseRemoteUrl } from "@plannotator/shared/repo";
79+
import { getReviewApprovedPrompt } from "@plannotator/shared/prompts";
7980
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
8081
import { openBrowser } from "@plannotator/server/browser";
8182
import { detectProjectName } from "@plannotator/server/project";
@@ -213,6 +214,7 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;
213214
// > Codex (CODEX_THREAD_ID)
214215
// > Copilot CLI (COPILOT_CLI)
215216
// > OpenCode (OPENCODE)
217+
// > Gemini CLI (GEMINI_CLI)
216218
// > Claude Code (default fallback)
217219
//
218220
// To add a new agent, also add an entry to AGENT_CONFIG in
@@ -223,6 +225,7 @@ const detectedOrigin: Origin =
223225
process.env.CODEX_THREAD_ID ? "codex" :
224226
process.env.COPILOT_CLI ? "copilot-cli" :
225227
process.env.OPENCODE ? "opencode" :
228+
process.env.GEMINI_CLI ? "gemini-cli" :
226229
"claude-code";
227230

228231
if (args[0] === "sessions") {
@@ -542,7 +545,7 @@ if (args[0] === "sessions") {
542545
if (result.exit) {
543546
console.log("Review session closed without feedback.");
544547
} else if (result.approved) {
545-
console.log("Code review completed — no changes requested.");
548+
console.log(getReviewApprovedPrompt(detectedOrigin));
546549
} else {
547550
console.log(result.feedback);
548551
if (!isPRMode) {

apps/marketing/src/content/docs/commands/code-review.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Review server starts, opens browser with diff viewer
4242
User annotates code, provides feedback
4343
4444
Send Feedback → feedback sent to agent
45-
Approve → "LGTM" sent to agent
45+
Approve → configured approval prompt sent to agent
4646
```
4747

4848
**PR review:**
@@ -59,7 +59,7 @@ Review server starts, opens browser with diff viewer
5959
User annotates code, provides feedback
6060
6161
Send Feedback → PR context included in feedback
62-
Approve → "LGTM" sent to agent
62+
Approve → configured approval prompt sent to agent
6363
```
6464

6565
## Stacked PRs and MRs
@@ -123,10 +123,37 @@ The review agents (Claude, Codex, Code Tour) shell out to external CLIs. Plannot
123123
## Submitting feedback
124124

125125
- **Send Feedback** formats your annotations and sends them to the agent
126-
- **Approve** sends "LGTM" to the agent, indicating the changes look good
126+
- **Approve** sends a review-approval prompt to the agent. By default this says no changes were requested, and you can override it in `~/.plannotator/config.json`.
127127

128128
After submission, the agent receives your feedback and can act on it, whether that's fixing issues, explaining decisions, or making the requested changes.
129129

130+
### Customizing the approval prompt
131+
132+
You can override the approval prompt in `~/.plannotator/config.json`.
133+
134+
```json
135+
{
136+
"prompts": {
137+
"review": {
138+
"approved": "# Code Review\n\nCommit these changes now.",
139+
"runtimes": {
140+
"opencode": {
141+
"approved": "# Code Review\n\nNo further changes requested. Commit your work."
142+
}
143+
}
144+
}
145+
}
146+
}
147+
```
148+
149+
Resolution order:
150+
151+
1. `prompts.review.runtimes.<runtime>.approved`
152+
2. `prompts.review.approved`
153+
3. Plannotator's built-in default
154+
155+
Runtime keys use Plannotator's runtime identifiers. For code review, the current values are `claude-code`, `opencode`, `copilot-cli`, `pi`, and `codex`.
156+
130157
## Server API
131158

132159
| Endpoint | Method | Purpose |

apps/marketing/src/content/docs/getting-started/configuration.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ sidebar:
66
section: "Getting Started"
77
---
88

9-
Plannotator is configured through environment variables and hook/plugin configuration files. No config file of its own is required.
9+
Plannotator is configured through environment variables, hook/plugin configuration files, and an optional `~/.plannotator/config.json` file for persistent settings and feature-specific overrides.
1010

1111
## Environment variables
1212

@@ -97,6 +97,12 @@ If you are upgrading from an older OpenCode install, see the [OpenCode 0.19.1 mi
9797

9898
Approved and denied plans are saved to `~/.plannotator/plans/` by default. You can change the save directory or disable saving in the Plannotator UI settings (gear icon).
9999

100+
## Config file
101+
102+
Plannotator also reads `~/.plannotator/config.json` for persistent settings and feature-specific overrides.
103+
104+
For example, code review approval prompts can be customized there. See the code review docs for the prompt shape and supported runtime keys.
105+
100106
## Remote mode
101107

102108
When working over SSH, in a devcontainer, or in Docker, set `PLANNOTATOR_REMOTE=1` (or `true`) and `PLANNOTATOR_PORT` to a port you'll forward. Set `PLANNOTATOR_REMOTE=0` / `false` if you need to force local behavior even when SSH env vars are present. See the [remote & devcontainers guide](/docs/guides/remote-and-devcontainers/) for setup instructions.

apps/opencode-plugin/commands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git";
2222
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
2323
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
24+
import { getReviewApprovedPrompt } from "@plannotator/shared/prompts";
2425
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
2526
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
2627
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
@@ -126,7 +127,7 @@ export async function handleReviewCommand(
126127
const targetAgent = result.agentSwitch || "build";
127128

128129
const message = result.approved
129-
? "# Code Review\n\nCode review completed — no changes requested."
130+
? getReviewApprovedPrompt("opencode")
130131
: isPRMode
131132
? result.feedback
132133
: `${result.feedback}\n\nPlease address this feedback.`;

apps/pi-extension/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
3737
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
3838
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
3939
import { loadConfig, resolveUseJina } from "./generated/config.js";
40+
import { getReviewApprovedPrompt } from "./generated/prompts.js";
4041
import { parseAnnotateArgs } from "./generated/annotate-args.js";
4142
import { resolveAtReference } from "./generated/at-reference.js";
4243
import {
@@ -313,7 +314,7 @@ export default function plannotator(pi: ExtensionAPI): void {
313314
} else if (result.feedback) {
314315
if (result.approved) {
315316
pi.sendUserMessage(
316-
`# Code Review\n\nCode review completed — no changes requested.`,
317+
getReviewApprovedPrompt("pi", loadConfig()),
317318
);
318319
} else if (isPRReview) {
319320
// Platform PR actions (approve/comment) return approved:false with a

apps/pi-extension/vendor.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
66

77
mkdir -p generated generated/ai/providers
88

9-
for f in feedback-templates review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
9+
for f in feedback-templates prompts review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
1010
src="../../packages/shared/$f.ts"
1111
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
1212
done

packages/shared/config.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,55 @@ export interface CCLabelConfig {
3131
blocking: boolean;
3232
}
3333

34+
export interface ReviewPromptOverrides {
35+
approved?: string;
36+
}
37+
38+
export type PromptRuntime =
39+
| "claude-code"
40+
| "opencode"
41+
| "copilot-cli"
42+
| "pi"
43+
| "codex"
44+
| "gemini-cli";
45+
46+
export interface PromptConfig {
47+
review?: {
48+
approved?: string;
49+
runtimes?: Partial<Record<PromptRuntime, ReviewPromptOverrides>>;
50+
};
51+
}
52+
53+
export function mergePromptConfig(
54+
current?: PromptConfig,
55+
partial?: PromptConfig,
56+
): PromptConfig | undefined {
57+
if (!current && !partial) return undefined;
58+
59+
const currentReview = current?.review;
60+
const partialReview = partial?.review;
61+
62+
const mergedReview = (currentReview || partialReview)
63+
? {
64+
...currentReview,
65+
...partialReview,
66+
runtimes: (currentReview?.runtimes || partialReview?.runtimes)
67+
? { ...currentReview?.runtimes, ...partialReview?.runtimes }
68+
: undefined,
69+
}
70+
: undefined;
71+
72+
return {
73+
...current,
74+
...partial,
75+
review: mergedReview,
76+
};
77+
}
78+
3479
export interface PlannotatorConfig {
3580
displayName?: string;
3681
diffOptions?: DiffOptions;
82+
prompts?: PromptConfig;
3783
conventionalComments?: boolean;
3884
/** null = explicitly cleared (use defaults), undefined = not set */
3985
conventionalLabels?: CCLabelConfig[] | null;
@@ -83,7 +129,13 @@ export function saveConfig(partial: Partial<PlannotatorConfig>): void {
83129
const mergedDiffOptions = (current.diffOptions || partial.diffOptions)
84130
? { ...current.diffOptions, ...partial.diffOptions }
85131
: undefined;
86-
const merged = { ...current, ...partial, diffOptions: mergedDiffOptions };
132+
const mergedPrompts = mergePromptConfig(current.prompts, partial.prompts);
133+
const merged = {
134+
...current,
135+
...partial,
136+
diffOptions: mergedDiffOptions,
137+
prompts: mergedPrompts,
138+
};
87139
mkdirSync(CONFIG_DIR, { recursive: true });
88140
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
89141
} catch (e) {

packages/shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"./external-annotation": "./external-annotation.ts",
2424
"./agent-jobs": "./agent-jobs.ts",
2525
"./config": "./config.ts",
26+
"./prompts": "./prompts.ts",
2627
"./improvement-hooks": "./improvement-hooks.ts",
2728
"./worktree": "./worktree.ts",
2829
"./worktree-pool": "./worktree-pool.ts",

packages/shared/prompts.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mergePromptConfig } from "./config";
3+
import { DEFAULT_REVIEW_APPROVED_PROMPT, getConfiguredPrompt, getReviewApprovedPrompt } from "./prompts";
4+
5+
describe("prompts", () => {
6+
test("falls back to built-in default when no config is present", () => {
7+
expect(getReviewApprovedPrompt("opencode", {})).toBe(DEFAULT_REVIEW_APPROVED_PROMPT);
8+
});
9+
10+
test("uses generic configured review approval prompt", () => {
11+
expect(
12+
getReviewApprovedPrompt("opencode", {
13+
prompts: { review: { approved: "Commit these changes now." } },
14+
}),
15+
).toBe("Commit these changes now.");
16+
});
17+
18+
test("runtime-specific review approval prompt wins over generic prompt", () => {
19+
expect(
20+
getReviewApprovedPrompt("opencode", {
21+
prompts: {
22+
review: {
23+
approved: "Generic approval.",
24+
runtimes: {
25+
opencode: { approved: "OpenCode-specific approval." },
26+
},
27+
},
28+
},
29+
}),
30+
).toBe("OpenCode-specific approval.");
31+
});
32+
33+
test("blank prompt values fall back to the next available default", () => {
34+
expect(
35+
getReviewApprovedPrompt("opencode", {
36+
prompts: {
37+
review: {
38+
approved: " ",
39+
runtimes: {
40+
opencode: { approved: "" },
41+
},
42+
},
43+
},
44+
}),
45+
).toBe(DEFAULT_REVIEW_APPROVED_PROMPT);
46+
});
47+
48+
test("generic loader resolves prompt paths with fallback", () => {
49+
expect(
50+
getConfiguredPrompt({
51+
section: "review",
52+
key: "approved",
53+
runtime: "pi",
54+
fallback: "Fallback",
55+
config: {
56+
prompts: {
57+
review: {
58+
runtimes: {
59+
pi: { approved: "Pi prompt" },
60+
},
61+
},
62+
},
63+
},
64+
}),
65+
).toBe("Pi prompt");
66+
});
67+
68+
test("mergePromptConfig keeps generic and sibling runtime prompts", () => {
69+
const merged = mergePromptConfig(
70+
{
71+
review: {
72+
approved: "Generic approval.",
73+
runtimes: {
74+
opencode: { approved: "OpenCode approval." },
75+
},
76+
},
77+
},
78+
{
79+
review: {
80+
runtimes: {
81+
"claude-code": { approved: "Claude approval." },
82+
},
83+
},
84+
},
85+
);
86+
87+
expect(merged?.review?.approved).toBe("Generic approval.");
88+
expect(merged?.review?.runtimes?.opencode?.approved).toBe("OpenCode approval.");
89+
expect(merged?.review?.runtimes?.["claude-code"]?.approved).toBe("Claude approval.");
90+
});
91+
});

packages/shared/prompts.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { loadConfig, type PlannotatorConfig, type PromptRuntime } from "./config";
2+
3+
export const DEFAULT_REVIEW_APPROVED_PROMPT = "# Code Review\n\nCode review completed — no changes requested.";
4+
5+
type PromptSection = "review";
6+
type PromptKey = "approved";
7+
8+
interface PromptLookupOptions {
9+
section: PromptSection;
10+
key: PromptKey;
11+
runtime?: PromptRuntime | null;
12+
config?: PlannotatorConfig;
13+
fallback: string;
14+
}
15+
16+
function normalizePrompt(prompt: string | undefined): string | undefined {
17+
const trimmed = prompt?.trim();
18+
return trimmed ? prompt : undefined;
19+
}
20+
21+
export function getConfiguredPrompt(options: PromptLookupOptions): string {
22+
const resolvedConfig = options.config ?? loadConfig();
23+
const section = resolvedConfig.prompts?.[options.section];
24+
const runtimePrompt = options.runtime
25+
? normalizePrompt(section?.runtimes?.[options.runtime]?.[options.key])
26+
: undefined;
27+
const genericPrompt = normalizePrompt(section?.[options.key]);
28+
29+
return runtimePrompt ?? genericPrompt ?? options.fallback;
30+
}
31+
32+
export function getReviewApprovedPrompt(
33+
runtime?: PromptRuntime | null,
34+
config?: PlannotatorConfig,
35+
): string {
36+
return getConfiguredPrompt({
37+
section: "review",
38+
key: "approved",
39+
runtime,
40+
config,
41+
fallback: DEFAULT_REVIEW_APPROVED_PROMPT,
42+
});
43+
}

0 commit comments

Comments
 (0)