mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user