Files
backnotprop__plannotator/apps/hook/server/index.ts
T
Michael Ramos 54e951e2f7 fix: don't ask agent to address feedback on LGTM approval (#293)
* fix: don't ask agent to address feedback on LGTM approval (#284)

When the reviewer approves with no annotations, send a neutral
"Code review completed — no changes requested." message instead of
the contradictory "LGTM" + "Please address this feedback."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use explicit approved flag instead of annotations.length heuristic

The previous fix inferred LGTM from an empty annotations array, but
VS Code editor annotations are carried in feedbackMarkdown without
populating the annotations array — causing real review comments to be
misclassified as approvals. Thread an explicit `approved` boolean from
the UI through the review server to all three integrations.

Closes #284

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: append assertive instruction to review feedback output

When the reviewer submits actual feedback, append "The reviewer has
identified issues above. You must address all of them." so the agent
treats annotations with urgency rather than soft-acknowledging them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: annotate unused LGTM feedback string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:58:57 -07:00

381 lines
12 KiB
TypeScript

/**
* Plannotator CLI for Claude Code
*
* Supports four modes:
*
* 1. Plan Review (default, no args):
* - Spawned by ExitPlanMode hook
* - Reads hook event from stdin, extracts plan content
* - Serves UI, returns approve/deny decision to stdout
*
* 2. Code Review (`plannotator review`):
* - Triggered by /review slash command
* - Runs git diff, opens review UI
* - Outputs feedback to stdout (captured by slash command)
*
* 3. Annotate (`plannotator annotate <file.md>`):
* - Triggered by /plannotator-annotate slash command
* - Opens any markdown file in the annotation UI
* - Outputs structured feedback to stdout
*
* 4. Sessions (`plannotator sessions`):
* - Lists active Plannotator server sessions
* - `--open [N]` reopens a session in the browser
* - `--clean` removes stale session files
*
* Global flags:
* --browser <name> - Override which browser to open (e.g. "Google Chrome")
*
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (preferred)
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
*/
import {
startPlannotatorServer,
handleServerReady,
} from "@plannotator/server";
import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
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 path from "path";
// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
import planHtml from "../dist/index.html" with { type: "text" };
const planHtmlContent = planHtml as unknown as string;
// @ts-ignore - Bun import attribute for text
import reviewHtml from "../dist/review.html" with { type: "text" };
const reviewHtmlContent = reviewHtml as unknown as string;
// Check for subcommand
const args = process.argv.slice(2);
// Global flag: --browser <name>
const browserIdx = args.indexOf("--browser");
if (browserIdx !== -1 && args[browserIdx + 1]) {
process.env.PLANNOTATOR_BROWSER = args[browserIdx + 1];
args.splice(browserIdx, 2);
}
// Ensure session cleanup on exit
process.on("exit", () => unregisterSession());
// Check if URL sharing is enabled (default: true)
const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled";
// Custom share portal URL for self-hosting
const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined;
// Paste service URL for short URL sharing
const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;
if (args[0] === "sessions") {
// ============================================
// SESSION DISCOVERY MODE
// ============================================
if (args.includes("--clean")) {
// Force cleanup: list sessions (which auto-removes stale entries)
const sessions = listSessions();
console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`);
process.exit(0);
}
const sessions = listSessions();
if (sessions.length === 0) {
console.error("No active Plannotator sessions.");
process.exit(0);
}
const openIdx = args.indexOf("--open");
if (openIdx !== -1) {
// Open a session in the browser
const nArg = args[openIdx + 1];
const n = nArg ? parseInt(nArg, 10) : 1;
const session = sessions[n - 1];
if (!session) {
console.error(`Session #${n} not found. ${sessions.length} active session(s).`);
process.exit(1);
}
await openBrowser(session.url);
console.error(`Opened ${session.mode} session in browser: ${session.url}`);
process.exit(0);
}
// List sessions as a table
console.error("Active Plannotator sessions:\n");
for (let i = 0; i < sessions.length; i++) {
const s = sessions[i];
const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000);
const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`;
console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`);
}
console.error(`\nReopen with: plannotator sessions --open [N]`);
process.exit(0);
} else if (args[0] === "review") {
// ============================================
// CODE REVIEW MODE
// ============================================
// Get git context (branches, available diff options)
const gitContext = await getGitContext();
// Run git diff HEAD (uncommitted changes - default)
const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiff(
"uncommitted",
gitContext.defaultBranch
);
const reviewProject = (await detectProjectName()) ?? "_unknown";
// Start review server (even if empty - user can switch diff types)
const server = await startReviewServer({
rawPatch,
gitRef,
error: diffError,
origin: "claude-code",
diffType: "uncommitted",
gitContext,
sharingEnabled,
shareBaseUrl,
htmlContent: reviewHtmlContent,
onReady: async (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);
if (isRemote && sharingEnabled && rawPatch) {
await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {});
}
},
});
registerSession({
pid: process.pid,
port: server.port,
url: server.url,
mode: "review",
project: reviewProject,
startedAt: new Date().toISOString(),
label: `review-${reviewProject}`,
});
// Wait for user feedback
const result = await server.waitForDecision();
// Give browser time to receive response and update UI
await Bun.sleep(1500);
// Cleanup
server.stop();
// Output feedback (captured by slash command)
if (result.approved) {
console.log("Code review completed — no changes requested.");
} else {
console.log(result.feedback);
console.log("\nThe reviewer has identified issues above. You must address all of them.");
}
process.exit(0);
} else if (args[0] === "annotate") {
// ============================================
// ANNOTATE MODE
// ============================================
let filePath = args[1];
if (!filePath) {
console.error("Usage: plannotator annotate <file.md>");
process.exit(1);
}
// Strip @ prefix if present (Claude Code file reference syntax)
if (filePath.startsWith("@")) {
filePath = filePath.slice(1);
}
// Use PLANNOTATOR_CWD if set (original working directory before script cd'd)
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
if (process.env.PLANNOTATOR_DEBUG) {
console.error(`[DEBUG] Project root: ${projectRoot}`);
console.error(`[DEBUG] File path arg: ${filePath}`);
}
// Smart file resolution: exact path, case-insensitive relative, or bare filename search
const resolved = await resolveMarkdownFile(filePath, projectRoot);
if (resolved.kind === "ambiguous") {
console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`);
for (const match of resolved.matches) {
console.error(` ${match}`);
}
process.exit(1);
}
if (resolved.kind === "not_found") {
console.error(`File not found: ${resolved.input}`);
process.exit(1);
}
const absolutePath = resolved.path;
console.error(`Resolved: ${absolutePath}`);
const markdown = await Bun.file(absolutePath).text();
const annotateProject = (await detectProjectName()) ?? "_unknown";
// Start the annotate server (reuses plan editor HTML)
const server = await startAnnotateServer({
markdown,
filePath: absolutePath,
origin: "claude-code",
sharingEnabled,
shareBaseUrl,
htmlContent: planHtmlContent,
onReady: async (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {});
}
},
});
registerSession({
pid: process.pid,
port: server.port,
url: server.url,
mode: "annotate",
project: annotateProject,
startedAt: new Date().toISOString(),
label: `annotate-${path.basename(absolutePath)}`,
});
// Wait for user feedback
const result = await server.waitForDecision();
// Give browser time to receive response and update UI
await Bun.sleep(1500);
// Cleanup
server.stop();
// Output feedback (captured by slash command)
console.log(result.feedback || "No feedback provided.");
process.exit(0);
} else {
// ============================================
// PLAN REVIEW MODE (default)
// ============================================
// Read hook event from stdin
const eventJson = await Bun.stdin.text();
let planContent = "";
let permissionMode = "default";
try {
const event = JSON.parse(eventJson);
planContent = event.tool_input?.plan || "";
permissionMode = event.permission_mode || "default";
} catch {
console.error("Failed to parse hook event from stdin");
process.exit(1);
}
if (!planContent) {
console.error("No plan content in hook event");
process.exit(1);
}
const planProject = (await detectProjectName()) ?? "_unknown";
// Start the plan review server
const server = await startPlannotatorServer({
plan: planContent,
origin: "claude-code",
permissionMode,
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
htmlContent: planHtmlContent,
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {});
}
},
});
registerSession({
pid: process.pid,
port: server.port,
url: server.url,
mode: "plan",
project: planProject,
startedAt: new Date().toISOString(),
label: `plan-${planProject}`,
});
// Wait for user decision (blocks until approve/deny)
const result = await server.waitForDecision();
// Give browser time to receive response and update UI
await Bun.sleep(1500);
// Cleanup
server.stop();
// Output JSON for PermissionRequest hook decision control
if (result.approved) {
// Build updatedPermissions to preserve the current permission mode
const updatedPermissions = [];
if (result.permissionMode) {
updatedPermissions.push({
type: "setMode",
mode: result.permissionMode,
destination: "session",
});
}
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "allow",
...(updatedPermissions.length > 0 && { updatedPermissions }),
},
},
})
);
} else {
console.log(
JSON.stringify({
hookSpecificOutput: {
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"}`,
},
},
})
);
}
process.exit(0);
}