Skip to content

Commit 64c845f

Browse files
authored
feat(feedback): configurable plan, annotation, and review feedback (#627)
Unified feedback pipeline for all plan approvals, plan denials, annotation feedback, and review suffixes. Users customize messages via config.json with {{variable}} template interpolation and per-runtime overrides. Closes #624 Co-authored-by: Aviad Shiber <aviadshiber@users.noreply.github.com> For provenance purposes, this commit was AI assisted.
1 parent b2eae46 commit 64c845f

12 files changed

Lines changed: 1391 additions & 79 deletions

File tree

apps/hook/server/index.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,17 @@ 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";
79+
import {
80+
getReviewApprovedPrompt,
81+
getReviewDeniedSuffix,
82+
getPlanDeniedPrompt,
83+
getPlanToolName,
84+
buildPlanFileRule,
85+
} from "@plannotator/shared/prompts";
8086
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
8187
import { openBrowser } from "@plannotator/server/browser";
8288
import { detectProjectName } from "@plannotator/server/project";
8389
import { hostnameOrFallback } from "@plannotator/shared/project";
84-
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
8590
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
8691
import { AGENT_CONFIG, type Origin } from "@plannotator/shared/agents";
8792
import {
@@ -154,7 +159,13 @@ if (hookFlag) gateFlag = true;
154159
//
155160
// Plaintext (default):
156161
// Close → empty. Approve → "The user approved." Annotate → feedback.
157-
export const APPROVED_PLAINTEXT_MARKER = "The user approved.";
162+
//
163+
// TODO: The plaintext --gate approval sentinel must stay as the exact string
164+
// "The user approved." because slash command templates (plannotator-annotate.md,
165+
// plannotator-last.md) instruct the agent to match it literally. Making this
166+
// configurable requires updating those templates to accept dynamic values or
167+
// switching gate mode to structured output only.
168+
const APPROVED_PLAINTEXT_MARKER = "The user approved.";
158169

159170
function emitAnnotateOutcome(result: {
160171
feedback: string;
@@ -549,7 +560,7 @@ if (args[0] === "sessions") {
549560
} else {
550561
console.log(result.feedback);
551562
if (!isPRMode) {
552-
console.log("\nThe reviewer has identified issues above. You must address all of them.");
563+
console.log(getReviewDeniedSuffix(detectedOrigin));
553564
}
554565
}
555566
process.exit(0);
@@ -950,10 +961,11 @@ if (args[0] === "sessions") {
950961
permissionDecision: "allow",
951962
}));
952963
} else {
953-
const feedback = planDenyFeedback(
954-
result.feedback || "",
955-
"exit_plan_mode",
956-
);
964+
const feedback = getPlanDeniedPrompt("copilot-cli", undefined, {
965+
toolName: getPlanToolName("copilot-cli"),
966+
planFileRule: "",
967+
feedback: result.feedback || "Plan changes requested",
968+
});
957969
console.log(JSON.stringify({
958970
permissionDecision: "deny",
959971
permissionDecisionReason: feedback,
@@ -1151,8 +1163,10 @@ if (args[0] === "sessions") {
11511163
console.log(
11521164
JSON.stringify({
11531165
decision: "deny",
1154-
reason: planDenyFeedback(result.feedback || "", "exit_plan_mode", {
1155-
planFilePath: planFilename,
1166+
reason: getPlanDeniedPrompt("gemini-cli", undefined, {
1167+
toolName: getPlanToolName("gemini-cli"),
1168+
planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename),
1169+
feedback: result.feedback || "Plan changes requested",
11561170
}),
11571171
})
11581172
);
@@ -1187,7 +1201,11 @@ if (args[0] === "sessions") {
11871201
hookEventName: "PermissionRequest",
11881202
decision: {
11891203
behavior: "deny",
1190-
message: planDenyFeedback(result.feedback || "", "ExitPlanMode"),
1204+
message: getPlanDeniedPrompt(detectedOrigin, undefined, {
1205+
toolName: getPlanToolName(detectedOrigin),
1206+
planFileRule: "",
1207+
feedback: result.feedback || "Plan changes requested",
1208+
}),
11911209
},
11921210
},
11931211
})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ Approved and denied plans are saved to `~/.plannotator/plans/` by default. You c
9999

100100
## Config file
101101

102-
Plannotator also reads `~/.plannotator/config.json` for persistent settings and feature-specific overrides.
102+
Plannotator reads `~/.plannotator/config.json` for persistent settings. This includes display name, diff options, conventional comment labels, and feedback message customization.
103103

104-
For example, code review approval prompts can be customized there. See the code review docs for the prompt shape and supported runtime keys.
104+
You can customize the messages Plannotator sends to the agent when you approve, deny, or annotate plans and documents. See the [custom feedback guide](/docs/guides/custom-feedback/) for the full config shape, template variables, and runtime-specific overrides.
105105

106106
## Remote mode
107107

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
---
2+
title: Custom Feedback Messages
3+
description: "How to customize the messages Plannotator sends to your agent when you approve, deny, or annotate plans and documents."
4+
sidebar:
5+
order: 29
6+
section: "Guides"
7+
---
8+
9+
Every time you approve a plan, deny it with feedback, annotate a file, or finish a code review, Plannotator sends a message to the agent. These messages are what the agent actually sees and acts on. By default they work well, but you can change any of them to match how you want your agent to behave.
10+
11+
All customization happens in `~/.plannotator/config.json` under the `prompts` key. No restart needed. Changes take effect the next time a feedback message is generated. You can set overrides that apply globally, or target a specific agent runtime (Claude Code, OpenCode, Pi, etc.) with [runtime-specific overrides](#runtime-specific-overrides).
12+
13+
## Quick example
14+
15+
Say you want a shorter, more direct plan denial message. Add a `prompts.plan.denied` override to your config file (`~/.plannotator/config.json`):
16+
17+
```json
18+
{
19+
"prompts": {
20+
"plan": {
21+
"denied": "PLAN REJECTED.\n\nFix these issues and resubmit via {{toolName}}:\n\n{{feedback}}"
22+
}
23+
}
24+
}
25+
```
26+
27+
Next time you deny a plan, the agent will see your custom message instead of the default. The `{{toolName}}` and `{{feedback}}` placeholders get filled in automatically.
28+
29+
## What you can customize
30+
31+
There are three sections: `plan`, `annotate`, and `review`. Each has its own set of message types.
32+
33+
### Plan feedback
34+
35+
These are sent when you approve or deny a plan in the review UI.
36+
37+
| Key | When it's used | Available variables |
38+
|-----|---------------|-------------------|
39+
| `denied` | You deny a plan (with or without annotations) | `{{toolName}}`, `{{feedback}}`, `{{planFileRule}}` |
40+
| `approved` | You approve a plan without notes | `{{planFilePath}}`, `{{doneMsg}}` |
41+
| `approvedWithNotes` | You approve but include annotation notes | `{{planFilePath}}`, `{{doneMsg}}`, `{{feedback}}` |
42+
| `autoApproved` | Plan is auto-approved in non-interactive mode | none |
43+
44+
### Annotation feedback
45+
46+
These are sent when you annotate a file (`/plannotator-annotate`) or the last assistant message (`/plannotator-last`).
47+
48+
| Key | When it's used | Available variables |
49+
|-----|---------------|-------------------|
50+
| `fileFeedback` | You annotate a file or folder | `{{fileHeader}}`, `{{filePath}}`, `{{feedback}}` |
51+
| `messageFeedback` | You annotate the last assistant message | `{{feedback}}` |
52+
53+
### Review feedback
54+
55+
These are sent during code review (`/plannotator-review`).
56+
57+
| Key | When it's used | Available variables |
58+
|-----|---------------|-------------------|
59+
| `approved` | You approve a code review with no feedback | none |
60+
| `denied` | Appended after your review feedback | none |
61+
62+
## Template variables
63+
64+
Templates use `{{variable}}` placeholders. Here's what each one contains:
65+
66+
| Variable | Description |
67+
|----------|-------------|
68+
| `{{feedback}}` | Your annotations, exported as structured text. This is the main content. |
69+
| `{{toolName}}` | The tool the agent needs to call to resubmit (`ExitPlanMode`, `submit_plan`, etc.). Varies by runtime. |
70+
| `{{planFileRule}}` | A conditional line telling the agent where the plan file is saved and how to edit it. Empty string when there's no file path. |
71+
| `{{planFilePath}}` | Path to the plan file being reviewed. |
72+
| `{{doneMsg}}` | Optional checklist instruction or save-path info, depending on the runtime. |
73+
| `{{fileHeader}}` | Either `"File"` or `"Folder"`, depending on what was annotated. |
74+
| `{{filePath}}` | Path to the annotated file or folder. |
75+
76+
If you use a `{{variable}}` that doesn't exist for that message type, it stays in the output as-is. This means you can include literal `{{text}}` in your templates without worrying about it being stripped.
77+
78+
## Runtime-specific overrides
79+
80+
Different agent runtimes (Claude Code, OpenCode, Pi, Gemini CLI, etc.) sometimes need different messages. For example, OpenCode's plan approval is shorter because the agent already knows it has tool access.
81+
82+
You can override a message for a specific runtime using the `runtimes` key:
83+
84+
```json
85+
{
86+
"prompts": {
87+
"plan": {
88+
"denied": "PLAN REJECTED.\n\n{{feedback}}",
89+
"runtimes": {
90+
"claude-code": {
91+
"denied": "Your plan was not approved. Address ALL feedback below, then call {{toolName}} again.\n\n{{feedback}}"
92+
},
93+
"opencode": {
94+
"denied": "Plan rejected. Fix the following and call {{toolName}}:\n\n{{feedback}}"
95+
}
96+
}
97+
}
98+
}
99+
}
100+
```
101+
102+
The resolution order is:
103+
104+
1. Runtime-specific config override (e.g., `prompts.plan.runtimes.opencode.denied`)
105+
2. Generic config override (e.g., `prompts.plan.denied`)
106+
3. Built-in default (some prompts have runtime-specific built-in defaults, like OpenCode's shorter plan approval)
107+
108+
Blank or whitespace-only values are treated as "not set" and fall through to the next level. This means you can clear a runtime override by setting it to `""` without affecting others.
109+
110+
Valid runtime keys: `claude-code`, `opencode`, `copilot-cli`, `pi`, `codex`, `gemini-cli`.
111+
112+
## Full config example
113+
114+
Here's a config that customizes several messages at once:
115+
116+
```json
117+
{
118+
"prompts": {
119+
"plan": {
120+
"denied": "PLAN NOT APPROVED.\n\nRevise your plan based on this feedback, then resubmit via {{toolName}}.\n\n{{feedback}}",
121+
"approved": "Plan approved. Begin implementation.",
122+
"approvedWithNotes": "Plan approved with the following notes:\n\n{{feedback}}\n\nKeep these in mind as you implement."
123+
},
124+
"annotate": {
125+
"fileFeedback": "# Annotations for {{filePath}}\n\n{{feedback}}\n\nPlease address these.",
126+
"messageFeedback": "{{feedback}}\n\nRevise your response based on these notes."
127+
},
128+
"review": {
129+
"approved": "Code review passed. No changes needed."
130+
}
131+
}
132+
}
133+
```
134+
135+
## Defaults
136+
137+
If you don't set any `prompts` config, everything works the same as it always has. The built-in defaults are the exact messages Plannotator has always sent. Here are the key ones for reference:
138+
139+
**Plan denied (default):**
140+
```
141+
YOUR PLAN WAS NOT APPROVED.
142+
143+
You MUST revise the plan to address ALL of the feedback below
144+
before calling {{toolName}} again.
145+
146+
Rules:
147+
{{planFileRule}}- Do not resubmit the same plan unchanged.
148+
- Do NOT change the plan title (first # heading) unless the
149+
user explicitly asks you to.
150+
151+
{{feedback}}
152+
```
153+
154+
**Plan approved (default, Pi runtime):**
155+
```
156+
Plan approved. You now have full tool access (read, bash, edit,
157+
write). Execute the plan in {{planFilePath}}. {{doneMsg}}
158+
```
159+
160+
**Annotate file feedback (default):**
161+
```
162+
# Markdown Annotations
163+
164+
{{fileHeader}}: {{filePath}}
165+
166+
{{feedback}}
167+
168+
Please address the annotation feedback above.
169+
```
170+
171+
## Example: context anchoring with a Decisions Log
172+
173+
When you deny a plan multiple times, the agent only sees the current round's feedback. It can re-propose the same rejected approach without realizing it was already rejected. Martin Fowler's [context anchoring](https://martinfowler.com/articles/reduce-friction-ai/context-anchoring.html) pattern solves this by having the agent maintain a running log of rejected decisions directly in the plan document.
174+
175+
You can implement this entirely through feedback customization. Add a `## Context Anchoring` section to your denial template that instructs the agent to keep a `## Decisions Log` in the plan itself:
176+
177+
```json
178+
{
179+
"prompts": {
180+
"plan": {
181+
"denied": "YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling {{toolName}} again.\n\nRules:\n{{planFileRule}}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n## Context Anchoring\n\nBefore revising your plan:\n1. Add (or update) a `## Decisions Log` section at the bottom of the plan.\n2. For each rejected approach from this feedback, add an entry:\n - **Rejected:** [brief description] **Why:** [reason from this feedback]\n3. Do NOT re-propose approaches already in the Decisions Log.\n\n{{feedback}}",
182+
"approved": "Plan approved. Begin implementation.\n\nIf your plan contains a `## Decisions Log`, keep it as a reference during implementation. It documents the rejected alternatives that shaped this design.",
183+
"approvedWithNotes": "Plan approved with the following notes:\n\n{{feedback}}\n\nKeep these in mind as you implement.\n\nIf your plan contains a `## Decisions Log`, keep it as a reference during implementation. It documents the rejected alternatives that shaped this design."
184+
}
185+
}
186+
}
187+
```
188+
189+
This works because the plan is already a persistent artifact (saved to version history on every submission). The Decisions Log travels with every revision, is visible in the plan diff view, and survives context window resets since it lives in the document, not just the conversation.
190+
191+
*This example is based on a contribution by [Aviad Shiber](https://github.com/aviadshiber).*
192+
193+
## Tips
194+
195+
- Start by customizing `plan.denied` since that's the message agents see most often during iterative planning.
196+
- Keep the `{{feedback}}` variable in your templates. Without it, the agent won't see your annotations.
197+
- The denial message framing matters. Claude was tested to respond better to strong, directive language ("You MUST revise") than softer phrasing. If you soften the tone too much, you may notice the agent ignoring feedback or resubmitting unchanged plans.
198+
- You can test your templates by denying a plan and checking what the agent receives. The full message shows up in the agent's conversation.

apps/marketing/src/styles/global.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,16 @@
8989
opacity: 0.8;
9090
}
9191

92-
.prose ul,
92+
.prose ul {
93+
padding-left: 1.5rem;
94+
margin-bottom: 0.75rem;
95+
list-style-type: disc;
96+
}
97+
9398
.prose ol {
9499
padding-left: 1.5rem;
95100
margin-bottom: 0.75rem;
101+
list-style-type: decimal;
96102
}
97103

98104
.prose li {

apps/opencode-plugin/commands.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ 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";
24+
import {
25+
getReviewApprovedPrompt,
26+
getReviewDeniedSuffix,
27+
getAnnotateFileFeedbackPrompt,
28+
} from "@plannotator/shared/prompts";
2529
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
2630
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
2731
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
@@ -130,7 +134,7 @@ export async function handleReviewCommand(
130134
? getReviewApprovedPrompt("opencode")
131135
: isPRMode
132136
? result.feedback
133-
: `${result.feedback}\n\nPlease address this feedback.`;
137+
: `${result.feedback}${getReviewDeniedSuffix("opencode")}`;
134138

135139
try {
136140
await client.session.prompt({
@@ -170,6 +174,7 @@ export async function handleAnnotateCommand(
170174
let absolutePath: string;
171175
let folderPath: string | undefined;
172176
let annotateMode: "annotate" | "annotate-folder" = "annotate";
177+
let isFolder = false;
173178
let sourceInfo: string | undefined;
174179
let sourceConverted = false;
175180

@@ -193,7 +198,6 @@ export async function handleAnnotateCommand(
193198
const projectRoot = directory || process.cwd();
194199
const resolvedArg = resolveUserPath(filePath, projectRoot);
195200

196-
let isFolder = false;
197201
try {
198202
isFolder = statSync(resolvedArg).isDirectory();
199203
} catch {
@@ -291,7 +295,11 @@ export async function handleAnnotateCommand(
291295
body: {
292296
parts: [{
293297
type: "text",
294-
text: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`,
298+
text: getAnnotateFileFeedbackPrompt("opencode", undefined, {
299+
fileHeader: isFolder ? "Folder" : "File",
300+
filePath: absolutePath,
301+
feedback: result.feedback,
302+
}),
295303
}],
296304
},
297305
});

0 commit comments

Comments
 (0)