Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/hook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dependencies": {
"@plannotator/editor": "workspace:*",
"@plannotator/server": "workspace:*",
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"react": "^19.2.3",
"react-dom": "^19.2.3",
Expand Down
3 changes: 2 additions & 1 deletion apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { resolveMarkdownFile } from "@plannotator/server/resolve-file";
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
import { openBrowser } from "@plannotator/server/browser";
import { detectProjectName } from "@plannotator/server/project";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
import path from "path";

// Embed the built HTML at compile time
Expand Down Expand Up @@ -369,7 +370,7 @@ if (args[0] === "sessions") {
hookEventName: "PermissionRequest",
decision: {
behavior: "deny",
message: `YOUR PLAN WAS NOT APPROVED. You MUST revise the plan to address ALL of the feedback below before calling ExitPlanMode again. Do not resubmit the same plan — use the Edit tool to make targeted changes to the plan file first.\n\n${result.feedback || "Plan changes requested"}`,
message: planDenyFeedback(result.feedback || "", "ExitPlanMode"),
},
},
})
Expand Down
14 changes: 2 additions & 12 deletions apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import { resolveMarkdownFile } from "@plannotator/server/resolve-file";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";

// @ts-ignore - Bun import attribute for text
import indexHtml from "./plannotator.html" with { type: "text" };
Expand Down Expand Up @@ -439,18 +440,7 @@ Proceed with implementation, incorporating these notes where applicable.`;
Plan Summary: ${args.summary}
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`;
} else {
return `Plan needs revision.
${result.savedPath ? `\nSaved to: ${result.savedPath}` : ""}

The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly.

## User Feedback

${result.feedback}

---

Please revise your plan based on this feedback and call \`submit_plan\` again when ready.`;
return planDenyFeedback(result.feedback || "", "submit_plan");
}
},
}),
Expand Down
3 changes: 2 additions & 1 deletion apps/opencode-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"@opencode-ai/plugin": "^1.1.10"
},
"devDependencies": {
"@plannotator/server": "workspace:*"
"@plannotator/server": "workspace:*",
"@plannotator/shared": "workspace:*"
},
"peerDependencies": {
"bun": ">=1.0.0"
Expand Down
20 changes: 20 additions & 0 deletions apps/pi-extension/feedback-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Vendored copy of packages/shared/feedback-templates.ts for source installs.
* Keep this file in sync with the shared source via `bun run build:pi`.
*/

export interface PlanDenyFeedbackOptions {
planFilePath?: string;
}

export const planDenyFeedback = (
feedback: string,
toolName: string = "ExitPlanMode",
options?: PlanDenyFeedbackOptions,
): string => {
const planFileRule = options?.planFilePath
? `- Read ${options.planFilePath} to see the current plan before editing it.\n`
: "";

return `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}- Use the Edit tool to make targeted changes to the plan — 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${feedback || "Plan changes requested"}`;
};
3 changes: 2 additions & 1 deletion apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
runGitDiff,
openBrowser,
} from "./server.js";
import { planDenyFeedback } from "./feedback-templates.js";

// Load HTML at runtime (jiti doesn't support import attributes)
const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -445,7 +446,7 @@ export default function plannotator(pi: ExtensionAPI): void {
content: [
{
type: "text",
text: `Plan not approved.\n\nUser feedback: ${feedbackText}\n\nRevise the plan:\n1. Read ${planFilePath} to see the current plan.\n2. Use the edit tool to make targeted changes addressing the feedback above — do not rewrite the entire file.\n3. Call exit_plan_mode again when ready.`,
text: planDenyFeedback(feedbackText, "exit_plan_mode", { planFilePath }),
},
],
details: { approved: false, feedback: feedbackText },
Expand Down
5 changes: 3 additions & 2 deletions apps/pi-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
"utils.ts",
"README.md",
"plannotator.html",
"review-editor.html"
"review-editor.html",
"feedback-templates.ts"
],
"scripts": {
"build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html",
"build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && cp ../../packages/shared/feedback-templates.ts feedback-templates.ts",
"prepublishOnly": "cd ../.. && bun run build:pi"
},
"peerDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions packages/shared/feedback-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, test, expect } from "bun:test";
import { planDenyFeedback } from "./feedback-templates";

describe("feedback-templates", () => {
/**
* The whole point of this module: all three integrations (hook, opencode, pi)
* produce identical output except for the tool name. If this test fails,
* the templates have diverged — which is what we're trying to prevent.
*/
test("plan deny is identical across integrations (modulo tool name)", () => {
const normalize = (s: string) =>
s.replace(/ExitPlanMode|submit_plan|exit_plan_mode/g, "TOOL");

const feedback = "## 1. Remove auth section\n> Not needed anymore.";
const hook = normalize(planDenyFeedback(feedback, "ExitPlanMode"));
const opencode = normalize(planDenyFeedback(feedback, "submit_plan"));
const pi = normalize(planDenyFeedback(feedback, "exit_plan_mode"));

expect(hook).toBe(opencode);
expect(opencode).toBe(pi);
});

/**
* The deny template must embed the user's feedback verbatim — no truncation,
* no escaping, no wrapping. The agent needs the raw annotation output.
*/
test("plan deny preserves feedback content verbatim", () => {
const feedback = "## 1. Change auth\n**From:**\n```\nold code\n```\n**To:**\n```\nnew code\n```";
const result = planDenyFeedback(feedback);
expect(result).toContain(feedback);
});

/**
* Empty feedback should not produce a broken message — the agent needs
* something actionable even if the user didn't write annotations.
*/
test("plan deny handles empty feedback gracefully", () => {
const result = planDenyFeedback("");
expect(result.length).toBeGreaterThan(50);
expect(result).toBe(result.trimEnd());
});

/**
* Version history is keyed by the plan's first # heading + date.
* If the agent renames the heading on resubmission, the version chain breaks
* and the user loses diffs (#296). The deny template must instruct the agent
* to preserve the title.
*/
test("plan deny instructs agent to preserve plan title", () => {
const result = planDenyFeedback("feedback");
expect(result.toLowerCase()).toContain("title");
expect(result.toLowerCase()).toContain("heading");
});

test("plan deny can include a plan file hint for file-based integrations", () => {
const result = planDenyFeedback("feedback", "exit_plan_mode", {
planFilePath: "plans/auth.md",
});

expect(result).toContain("Read plans/auth.md to see the current plan before editing it.");
expect(result).toContain("exit_plan_mode");
});
});
22 changes: 22 additions & 0 deletions packages/shared/feedback-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Shared feedback templates for all agent integrations.
*
* The plan deny template was tuned in #224 / commit 3dca977 to use strong
* directive framing — Claude was ignoring softer phrasing.
*/

export interface PlanDenyFeedbackOptions {
planFilePath?: string;
}

export const planDenyFeedback = (
feedback: string,
toolName: string = "ExitPlanMode",
options?: PlanDenyFeedbackOptions,
): string => {
const planFileRule = options?.planFilePath
? `- Read ${options.planFilePath} to see the current plan before editing it.\n`
: "";

return `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}- Use the Edit tool to make targeted changes to the plan — 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${feedback || "Plan changes requested"}`;
};
3 changes: 2 additions & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"exports": {
"./compress": "./compress.ts",
"./crypto": "./crypto.ts"
"./crypto": "./crypto.ts",
"./feedback-templates": "./feedback-templates.ts"
}
}
6 changes: 3 additions & 3 deletions packages/ui/utils/parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Block, type Annotation, type EditorAnnotation, type ImageAttachment } from '../types';
import { planDenyFeedback } from '@plannotator/shared/feedback-templates';

/**
* Parsed YAML frontmatter as key-value pairs.
Expand Down Expand Up @@ -244,9 +245,8 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
};

/** Wrap feedback output with the deny preamble for pasting into agent sessions */
export const wrapFeedbackForAgent = (feedback: string): string => {
return `YOUR PLAN WAS NOT APPROVED. You MUST revise the plan to address ALL of the feedback below before calling ExitPlanMode again. Do not resubmit the same plan — use the Edit tool to make targeted changes to the plan file first.\n\n${feedback}`;
};
export const wrapFeedbackForAgent = (feedback: string): string =>
planDenyFeedback(feedback);

export const exportAnnotations = (blocks: Block[], annotations: any[], globalAttachments: ImageAttachment[] = []): string => {
if (annotations.length === 0 && globalAttachments.length === 0) {
Expand Down
Loading