From b2eae468d7c55d36c229977eb69fde49fd23f151 Mon Sep 17 00:00:00 2001 From: Orestis Ioannou Date: Wed, 29 Apr 2026 01:59:19 +0300 Subject: [PATCH] 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 --- apps/hook/server/index.ts | 5 +- .../src/content/docs/commands/code-review.md | 33 ++++++- .../docs/getting-started/configuration.md | 8 +- apps/opencode-plugin/commands.ts | 3 +- apps/pi-extension/index.ts | 3 +- apps/pi-extension/vendor.sh | 2 +- packages/shared/config.ts | 54 ++++++++++- packages/shared/package.json | 1 + packages/shared/prompts.test.ts | 91 +++++++++++++++++++ packages/shared/prompts.ts | 43 +++++++++ 10 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 packages/shared/prompts.test.ts create mode 100644 packages/shared/prompts.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index d80e54c6..537484c4 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -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) { diff --git a/apps/marketing/src/content/docs/commands/code-review.md b/apps/marketing/src/content/docs/commands/code-review.md index 877cac07..895d91dd 100644 --- a/apps/marketing/src/content/docs/commands/code-review.md +++ b/apps/marketing/src/content/docs/commands/code-review.md @@ -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..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 | diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md index dc3c158a..b0626cc0 100644 --- a/apps/marketing/src/content/docs/getting-started/configuration.md +++ b/apps/marketing/src/content/docs/getting-started/configuration.md @@ -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. diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 29693777..119e17f9 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -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.`; diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index dd8bfb43..2706c8d7 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -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 diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index f24b2ab9..9a362355 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -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 diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 7a6de41c..c07b7bae 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -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>; + }; +} + +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): 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) { diff --git a/packages/shared/package.json b/packages/shared/package.json index 87676f42..51b58053 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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", diff --git a/packages/shared/prompts.test.ts b/packages/shared/prompts.test.ts new file mode 100644 index 00000000..f8c7e0cf --- /dev/null +++ b/packages/shared/prompts.test.ts @@ -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."); + }); +}); diff --git a/packages/shared/prompts.ts b/packages/shared/prompts.ts new file mode 100644 index 00000000..6f766180 --- /dev/null +++ b/packages/shared/prompts.ts @@ -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, + }); +}