diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 89284c5e..97bf9ea1 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -74,6 +74,7 @@ import { resolveMarkdownFile, hasMarkdownFiles } from "@plannotator/shared/resol 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"; @@ -443,7 +444,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 2c8e2d79..aa7df052 100644 --- a/apps/marketing/src/content/docs/commands/code-review.md +++ b/apps/marketing/src/content/docs/commands/code-review.md @@ -40,7 +40,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:** @@ -57,7 +57,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 ``` ## Switching diff types @@ -109,10 +109,37 @@ If only one provider is installed, it's used automatically with no configuration ## 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 43dca687..cd1ce6c4 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 @@ -63,6 +63,12 @@ This registers the `submit_plan` tool. Slash commands (`/plannotator-review`, `/ 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 4f1350c2..e346e24a 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 } from "@plannotator/shared/resolve-file"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; @@ -123,7 +124,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 afe9f472..57c784ae 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -39,6 +39,7 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js"; import { htmlToMarkdown } from "./generated/html-to-markdown.js"; import { urlToMarkdown } from "./generated/url-to-markdown.js"; import { loadConfig, resolveUseJina } from "./generated/config.js"; +import { getReviewApprovedPrompt } from "./generated/prompts.js"; import { getLastAssistantMessageText, hasPlanBrowserHtml, @@ -355,7 +356,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 e7dfce03..c17bacbc 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-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown; do +for f in feedback-templates prompts review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown; 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 eeba1b95..3ab00248 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 a8a9389a..190756e4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -22,6 +22,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", "./html-to-markdown": "./html-to-markdown.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, + }); +}