Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "plannotator",
"owner": {
"name": "backnotprop"
"name": "bradennapier"
},
"plugins": [
{
Expand Down
8 changes: 4 additions & 4 deletions apps/hook/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "plannotator",
"description": "Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with plan mode hooks.",
"version": "0.2.1",
"version": "0.3.0",
"author": {
"name": "backnotprop"
"name": "bradennapier"
},
"repository": "https://github.com/backnotprop/plannotator",
"keywords": ["planning", "review", "hooks", "ExitPlanMode", "collaboration"]
"repository": "https://github.com/bradennapier/plannotator",
"keywords": ["planning", "review", "hooks", "ExitPlanMode", "collaboration", "spec-driven"]
}
46 changes: 39 additions & 7 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ if (!planContent) {
}

// Promise that resolves when user makes a decision
let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
let resolveDecision: (result: { approved: boolean; feedback?: string; instructions?: string }) => void;
const decisionPromise = new Promise<{ approved: boolean; feedback?: string; instructions?: string }>(
(resolve) => { resolveDecision = resolve; }
);

Expand All @@ -87,15 +87,24 @@ async function startServer(): Promise<ReturnType<typeof Bun.serve>> {

// API: Approve plan
if (url.pathname === "/api/approve" && req.method === "POST") {
resolveDecision({ approved: true });
try {
const body = await req.json() as { instructions?: string };
resolveDecision({ approved: true, instructions: body.instructions });
} catch {
resolveDecision({ approved: true });
}
return Response.json({ ok: true });
}

// API: Deny with feedback
if (url.pathname === "/api/deny" && req.method === "POST") {
try {
const body = await req.json() as { feedback?: string };
resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
const body = await req.json() as { feedback?: string; instructions?: string };
resolveDecision({
approved: false,
feedback: body.feedback || "Plan rejected by user",
instructions: body.instructions
});
} catch {
resolveDecision({ approved: false, feedback: "Plan rejected by user" });
}
Expand Down Expand Up @@ -170,21 +179,44 @@ server.stop();

// Output JSON for PermissionRequest hook decision control
if (result.approved) {
// Build approval message with instructions
let message = "";

if (result.instructions) {
message = `# Plan Approved\n\nThe user has approved this plan. Please follow these instructions:\n\n${result.instructions}`;
}

console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "allow"
behavior: "allow",
...(message && message.trim() ? { message: message.trim() } : {})
}
}
}));
} else {
// Build denial message with feedback and instructions
let message = "# Plan Changes Requested\n\n";

if (result.feedback) {
message += `## Feedback\n\n${result.feedback}\n\n`;
}

if (result.instructions) {
message += `## Additional Instructions\n\n${result.instructions}`;
}

if (!result.feedback && !result.instructions) {
message = "Plan changes requested by user";
}

console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "deny",
message: result.feedback || "Plan changes requested"
message: message.trim()
}
}
}));
Expand Down
37 changes: 28 additions & 9 deletions bun.lock

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

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
{
"name": "plannotator",
"version": "0.2.1",
"version": "0.3.0",
"private": true,
"description": "Interactive Plan Review for Claude Code - annotate plans visually, share with team, automatically send feedback",
"author": "backnotprop",
"author": "bradennapier",
"license": "BSL-1.1",
"repository": {
"type": "git",
"url": "git+https://github.com/backnotprop/plannotator.git"
"url": "git+https://github.com/bradennapier/plannotator.git"
},
"homepage": "https://github.com/backnotprop/plannotator",
"homepage": "https://plannotator.ai",
"bugs": {
"url": "https://github.com/backnotprop/plannotator/issues"
"url": "https://github.com/bradennapier/plannotator/issues"
},
"workspaces": ["apps/*", "packages/*"],
"scripts": {
Expand Down
55 changes: 51 additions & 4 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Settings } from '@plannotator/ui/components/Settings';
import { useSharing } from '@plannotator/ui/hooks/useSharing';
import { storage } from '@plannotator/ui/utils/storage';
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
import { InstructionsPrompt, generateDefaultInstructions } from '@plannotator/ui/components/InstructionsPrompt';

const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration

Expand Down Expand Up @@ -229,8 +230,20 @@ const App: React.FC = () => {
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
const [instructions, setInstructions] = useState(() => {
// Restore instructions from cookies to persist across plan revisions
return storage.getItem('plannotator-instructions') || '';
});
const [isInstructionsExpanded, setIsInstructionsExpanded] = useState(true);
const viewerRef = useRef<ViewerHandle>(null);

// Persist instructions to cookies when they change
useEffect(() => {
if (instructions.trim()) {
storage.setItem('plannotator-instructions', instructions);
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instructions are only persisted to storage when they have content (instructions.trim() is truthy), but there's no mechanism to remove them from storage when the user clears the instructions field. This means once instructions are set, they can never be fully cleared from storage even if the user deletes all text. Consider also calling storage.removeItem('plannotator-instructions') when instructions.trim() is empty.

Suggested change
storage.setItem('plannotator-instructions', instructions);
storage.setItem('plannotator-instructions', instructions);
} else {
storage.removeItem('plannotator-instructions');

Copilot uses AI. Check for mistakes.
}
}, [instructions]);

// URL-based sharing
const {
isSharedSession,
Expand All @@ -239,6 +252,7 @@ const App: React.FC = () => {
shareUrlSize,
pendingSharedAnnotations,
clearPendingSharedAnnotations,
loadedInstructions,
} = useSharing(
markdown,
annotations,
Expand All @@ -247,9 +261,17 @@ const App: React.FC = () => {
() => {
// When loaded from share, mark as loaded
setIsLoading(false);
}
},
instructions
);

// Apply loaded instructions from shared URL
useEffect(() => {
if (loadedInstructions && !instructions.trim()) {
setInstructions(loadedInstructions);
}
}, [loadedInstructions, instructions]);
Comment on lines +268 to +273
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect will continuously overwrite user-set instructions with loaded instructions whenever the instructions state changes to an empty/whitespace string. If a user intentionally clears instructions that were previously loaded from a shared URL, this effect will restore them. The dependency array includes 'instructions' which means this will trigger on every instruction change, potentially creating unexpected behavior. Consider removing the 'instructions' dependency or using a ref/flag to track whether instructions have been loaded once.

Copilot uses AI. Check for mistakes.

// Apply shared annotations to DOM after they're loaded
useEffect(() => {
if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) {
Expand Down Expand Up @@ -295,11 +317,22 @@ const App: React.FC = () => {
setBlocks(parseMarkdownToBlocks(markdown));
}, [markdown]);

// Generate default instructions when plan is loaded in API mode
useEffect(() => {
if (isApiMode && markdown && !instructions.trim()) {
setInstructions(generateDefaultInstructions(markdown));
}
}, [isApiMode, markdown, instructions]);

Comment on lines +322 to +326
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect generates default instructions every time the markdown changes if instructions are empty. This could overwrite instructions that a user intentionally cleared. Additionally, the dependency array includes 'instructions' which means this effect runs whenever instructions change, potentially resetting them when the user clears the field. Consider using a ref or flag to track whether default instructions have been generated once for a given plan load.

Suggested change
if (isApiMode && markdown && !instructions.trim()) {
setInstructions(generateDefaultInstructions(markdown));
}
}, [isApiMode, markdown, instructions]);
if (!isApiMode || !markdown) {
return;
}
setInstructions((prev) => {
// If the user or other logic has already provided instructions, keep them.
if (prev.trim()) {
return prev;
}
return generateDefaultInstructions(markdown);
});
}, [isApiMode, markdown]);

Copilot uses AI. Check for mistakes.
// API mode handlers
const handleApprove = async () => {
setIsSubmitting(true);
try {
await fetch('/api/approve', { method: 'POST' });
await fetch('/api/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instructions: instructions.trim() })
});
setSubmitted('approved');
} catch {
setIsSubmitting(false);
Expand All @@ -312,7 +345,10 @@ const App: React.FC = () => {
await fetch('/api/deny', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback: diffOutput })
body: JSON.stringify({
feedback: diffOutput,
instructions: instructions.trim()
})
});
setSubmitted('denied');
} catch {
Expand Down Expand Up @@ -435,6 +471,17 @@ const App: React.FC = () => {
{/* Document Area */}
<main className="flex-1 overflow-y-auto bg-grid">
<div className="min-h-full flex flex-col items-center p-3 md:p-8">
{/* Instructions Prompt - API mode only */}
{isApiMode && (
<InstructionsPrompt
value={instructions}
onChange={setInstructions}
isExpanded={isInstructionsExpanded}
onToggleExpand={() => setIsInstructionsExpanded(!isInstructionsExpanded)}
disabled={isSubmitting}
/>
)}

{/* Mode Switcher */}
<div className="w-full max-w-3xl mb-3 md:mb-4 flex justify-start">
<ModeSwitcher mode={editorMode} onChange={setEditorMode} taterMode={taterMode} />
Expand Down Expand Up @@ -530,7 +577,7 @@ const App: React.FC = () => {
</h2>
<p className="text-muted-foreground">
{submitted === 'approved'
? 'Claude will proceed with the implementation.'
? 'Claude will follow your instructions (save plan, etc.).'
: 'Claude will revise the plan based on your annotations.'}
</p>
</div>
Expand Down
Loading