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>
This commit is contained in:
Orestis Ioannou
2026-04-29 01:59:19 +03:00
committed by GitHub
parent 93c844021d
commit b2eae468d7
10 changed files with 234 additions and 9 deletions
+4 -1
View File
@@ -76,6 +76,7 @@ import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannot
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { statSync, rmSync, realpathSync, existsSync } from "fs";
import { parseRemoteUrl } from "@plannotator/shared/repo";
import { getReviewApprovedPrompt } from "@plannotator/shared/prompts";
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
import { openBrowser } from "@plannotator/server/browser";
import { detectProjectName } from "@plannotator/server/project";
@@ -213,6 +214,7 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;
// > Codex (CODEX_THREAD_ID)
// > Copilot CLI (COPILOT_CLI)
// > OpenCode (OPENCODE)
// > Gemini CLI (GEMINI_CLI)
// > Claude Code (default fallback)
//
// To add a new agent, also add an entry to AGENT_CONFIG in
@@ -223,6 +225,7 @@ const detectedOrigin: Origin =
process.env.CODEX_THREAD_ID ? "codex" :
process.env.COPILOT_CLI ? "copilot-cli" :
process.env.OPENCODE ? "opencode" :
process.env.GEMINI_CLI ? "gemini-cli" :
"claude-code";
if (args[0] === "sessions") {
@@ -542,7 +545,7 @@ if (args[0] === "sessions") {
if (result.exit) {
console.log("Review session closed without feedback.");
} else if (result.approved) {
console.log("Code review completed — no changes requested.");
console.log(getReviewApprovedPrompt(detectedOrigin));
} else {
console.log(result.feedback);
if (!isPRMode) {
@@ -42,7 +42,7 @@ Review server starts, opens browser with diff viewer
User annotates code, provides feedback
↓
Send Feedback → feedback sent to agent
Approve → "LGTM" sent to agent
Approve → configured approval prompt sent to agent
```
**PR review:**
@@ -59,7 +59,7 @@ Review server starts, opens browser with diff viewer
User annotates code, provides feedback
↓
Send Feedback → PR context included in feedback
Approve → "LGTM" sent to agent
Approve → configured approval prompt sent to agent
```
## Stacked PRs and MRs
@@ -123,10 +123,37 @@ The review agents (Claude, Codex, Code Tour) shell out to external CLIs. Plannot
## Submitting feedback
- **Send Feedback** formats your annotations and sends them to the agent
- **Approve** sends "LGTM" to the agent, indicating the changes look good
- **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`.
After submission, the agent receives your feedback and can act on it, whether that's fixing issues, explaining decisions, or making the requested changes.
### Customizing the approval prompt
You can override the approval prompt in `~/.plannotator/config.json`.
```json
{
"prompts": {
"review": {
"approved": "# Code Review\n\nCommit these changes now.",
"runtimes": {
"opencode": {
"approved": "# Code Review\n\nNo further changes requested. Commit your work."
}
}
}
}
}
```
Resolution order:
1. `prompts.review.runtimes.<runtime>.approved`
2. `prompts.review.approved`
3. Plannotator's built-in default
Runtime keys use Plannotator's runtime identifiers. For code review, the current values are `claude-code`, `opencode`, `copilot-cli`, `pi`, and `codex`.
## Server API
| Endpoint | Method | Purpose |
@@ -6,7 +6,7 @@ sidebar:
section: "Getting Started"
---
Plannotator is configured through environment variables and hook/plugin configuration files. No config file of its own is required.
Plannotator is configured through environment variables, hook/plugin configuration files, and an optional `~/.plannotator/config.json` file for persistent settings and feature-specific overrides.
## Environment variables
@@ -97,6 +97,12 @@ If you are upgrading from an older OpenCode install, see the [OpenCode 0.19.1 mi
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).
## Config file
Plannotator also reads `~/.plannotator/config.json` for persistent settings and feature-specific overrides.
For example, code review approval prompts can be customized there. See the code review docs for the prompt shape and supported runtime keys.
## Remote mode
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.
+2 -1
View File
@@ -21,6 +21,7 @@ import {
import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import { getReviewApprovedPrompt } from "@plannotator/shared/prompts";
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
@@ -126,7 +127,7 @@ export async function handleReviewCommand(
const targetAgent = result.agentSwitch || "build";
const message = result.approved
? "# Code Review\n\nCode review completed — no changes requested."
? getReviewApprovedPrompt("opencode")
: isPRMode
? result.feedback
: `${result.feedback}\n\nPlease address this feedback.`;
+2 -1
View File
@@ -37,6 +37,7 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
import { loadConfig, resolveUseJina } from "./generated/config.js";
import { getReviewApprovedPrompt } from "./generated/prompts.js";
import { parseAnnotateArgs } from "./generated/annotate-args.js";
import { resolveAtReference } from "./generated/at-reference.js";
import {
@@ -313,7 +314,7 @@ export default function plannotator(pi: ExtensionAPI): void {
} else if (result.feedback) {
if (result.approved) {
pi.sendUserMessage(
`# Code Review\n\nCode review completed — no changes requested.`,
getReviewApprovedPrompt("pi", loadConfig()),
);
} else if (isPRReview) {
// Platform PR actions (approve/comment) return approved:false with a
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
mkdir -p generated generated/ai/providers
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
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
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
+53 -1
View File
@@ -31,9 +31,55 @@ export interface CCLabelConfig {
blocking: boolean;
}
export interface ReviewPromptOverrides {
approved?: string;
}
export type PromptRuntime =
| "claude-code"
| "opencode"
| "copilot-cli"
| "pi"
| "codex"
| "gemini-cli";
export interface PromptConfig {
review?: {
approved?: string;
runtimes?: Partial<Record<PromptRuntime, ReviewPromptOverrides>>;
};
}
export function mergePromptConfig(
current?: PromptConfig,
partial?: PromptConfig,
): PromptConfig | undefined {
if (!current && !partial) return undefined;
const currentReview = current?.review;
const partialReview = partial?.review;
const mergedReview = (currentReview || partialReview)
? {
...currentReview,
...partialReview,
runtimes: (currentReview?.runtimes || partialReview?.runtimes)
? { ...currentReview?.runtimes, ...partialReview?.runtimes }
: undefined,
}
: undefined;
return {
...current,
...partial,
review: mergedReview,
};
}
export interface PlannotatorConfig {
displayName?: string;
diffOptions?: DiffOptions;
prompts?: PromptConfig;
conventionalComments?: boolean;
/** null = explicitly cleared (use defaults), undefined = not set */
conventionalLabels?: CCLabelConfig[] | null;
@@ -83,7 +129,13 @@ export function saveConfig(partial: Partial<PlannotatorConfig>): void {
const mergedDiffOptions = (current.diffOptions || partial.diffOptions)
? { ...current.diffOptions, ...partial.diffOptions }
: undefined;
const merged = { ...current, ...partial, diffOptions: mergedDiffOptions };
const mergedPrompts = mergePromptConfig(current.prompts, partial.prompts);
const merged = {
...current,
...partial,
diffOptions: mergedDiffOptions,
prompts: mergedPrompts,
};
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
} catch (e) {
+1
View File
@@ -23,6 +23,7 @@
"./external-annotation": "./external-annotation.ts",
"./agent-jobs": "./agent-jobs.ts",
"./config": "./config.ts",
"./prompts": "./prompts.ts",
"./improvement-hooks": "./improvement-hooks.ts",
"./worktree": "./worktree.ts",
"./worktree-pool": "./worktree-pool.ts",
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { mergePromptConfig } from "./config";
import { DEFAULT_REVIEW_APPROVED_PROMPT, getConfiguredPrompt, getReviewApprovedPrompt } from "./prompts";
describe("prompts", () => {
test("falls back to built-in default when no config is present", () => {
expect(getReviewApprovedPrompt("opencode", {})).toBe(DEFAULT_REVIEW_APPROVED_PROMPT);
});
test("uses generic configured review approval prompt", () => {
expect(
getReviewApprovedPrompt("opencode", {
prompts: { review: { approved: "Commit these changes now." } },
}),
).toBe("Commit these changes now.");
});
test("runtime-specific review approval prompt wins over generic prompt", () => {
expect(
getReviewApprovedPrompt("opencode", {
prompts: {
review: {
approved: "Generic approval.",
runtimes: {
opencode: { approved: "OpenCode-specific approval." },
},
},
},
}),
).toBe("OpenCode-specific approval.");
});
test("blank prompt values fall back to the next available default", () => {
expect(
getReviewApprovedPrompt("opencode", {
prompts: {
review: {
approved: " ",
runtimes: {
opencode: { approved: "" },
},
},
},
}),
).toBe(DEFAULT_REVIEW_APPROVED_PROMPT);
});
test("generic loader resolves prompt paths with fallback", () => {
expect(
getConfiguredPrompt({
section: "review",
key: "approved",
runtime: "pi",
fallback: "Fallback",
config: {
prompts: {
review: {
runtimes: {
pi: { approved: "Pi prompt" },
},
},
},
},
}),
).toBe("Pi prompt");
});
test("mergePromptConfig keeps generic and sibling runtime prompts", () => {
const merged = mergePromptConfig(
{
review: {
approved: "Generic approval.",
runtimes: {
opencode: { approved: "OpenCode approval." },
},
},
},
{
review: {
runtimes: {
"claude-code": { approved: "Claude approval." },
},
},
},
);
expect(merged?.review?.approved).toBe("Generic approval.");
expect(merged?.review?.runtimes?.opencode?.approved).toBe("OpenCode approval.");
expect(merged?.review?.runtimes?.["claude-code"]?.approved).toBe("Claude approval.");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { loadConfig, type PlannotatorConfig, type PromptRuntime } from "./config";
export const DEFAULT_REVIEW_APPROVED_PROMPT = "# Code Review\n\nCode review completed — no changes requested.";
type PromptSection = "review";
type PromptKey = "approved";
interface PromptLookupOptions {
section: PromptSection;
key: PromptKey;
runtime?: PromptRuntime | null;
config?: PlannotatorConfig;
fallback: string;
}
function normalizePrompt(prompt: string | undefined): string | undefined {
const trimmed = prompt?.trim();
return trimmed ? prompt : undefined;
}
export function getConfiguredPrompt(options: PromptLookupOptions): string {
const resolvedConfig = options.config ?? loadConfig();
const section = resolvedConfig.prompts?.[options.section];
const runtimePrompt = options.runtime
? normalizePrompt(section?.runtimes?.[options.runtime]?.[options.key])
: undefined;
const genericPrompt = normalizePrompt(section?.[options.key]);
return runtimePrompt ?? genericPrompt ?? options.fallback;
}
export function getReviewApprovedPrompt(
runtime?: PromptRuntime | null,
config?: PlannotatorConfig,
): string {
return getConfiguredPrompt({
section: "review",
key: "approved",
runtime,
config,
fallback: DEFAULT_REVIEW_APPROVED_PROMPT,
});
}