From 4b2a37d361cb001b870fec59cdb9113cfccd9ffe Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:00:41 -0700 Subject: [PATCH 01/26] =?UTF-8?q?chore(annotate):=20start=20#570=20?= =?UTF-8?q?=E2=80=94=20approve/annotate/dismiss=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft PR placeholder. Design in description. For provenance purposes, this commit was AI assisted. From a053e0d2c67ff9ba8f07edda0c6adf53dac60626 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:20:00 -0700 Subject: [PATCH 02/26] feat(shared): add parseAnnotateArgs utility + tests Parses --gate and --json flags from a raw args string for the OpenCode plugin and Pi extension (both receive pre-joined arg strings from their harness slash-command dispatchers). Claude Code's binary continues to use argv indexOf/splice directly. Part of #570. For provenance purposes, this commit was AI assisted. --- packages/shared/annotate-args.test.ts | 100 ++++++++++++++++++++++++++ packages/shared/annotate-args.ts | 29 ++++++++ packages/shared/package.json | 3 +- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/shared/annotate-args.test.ts create mode 100644 packages/shared/annotate-args.ts diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts new file mode 100644 index 00000000..9399ff7d --- /dev/null +++ b/packages/shared/annotate-args.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from "bun:test"; +import { parseAnnotateArgs } from "./annotate-args"; + +describe("parseAnnotateArgs", () => { + test("path only", () => { + expect(parseAnnotateArgs("spec.md")).toEqual({ + filePath: "spec.md", + gate: false, + json: false, + }); + }); + + test("path with --gate at end", () => { + expect(parseAnnotateArgs("spec.md --gate")).toEqual({ + filePath: "spec.md", + gate: true, + json: false, + }); + }); + + test("--gate before path", () => { + expect(parseAnnotateArgs("--gate spec.md")).toEqual({ + filePath: "spec.md", + gate: true, + json: false, + }); + }); + + test("path with both flags", () => { + expect(parseAnnotateArgs("spec.md --gate --json")).toEqual({ + filePath: "spec.md", + gate: true, + json: true, + }); + }); + + test("flags only, no path", () => { + expect(parseAnnotateArgs("--gate --json")).toEqual({ + filePath: "", + gate: true, + json: true, + }); + }); + + test("path with spaces rejoins with single space", () => { + expect(parseAnnotateArgs("my file.md --gate")).toEqual({ + filePath: "my file.md", + gate: true, + json: false, + }); + }); + + test("leading @ is stripped", () => { + expect(parseAnnotateArgs("@spec.md --gate")).toEqual({ + filePath: "spec.md", + gate: true, + json: false, + }); + }); + + test("URL passes through", () => { + expect(parseAnnotateArgs("https://example.com/docs --gate")).toEqual({ + filePath: "https://example.com/docs", + gate: true, + json: false, + }); + }); + + test("extra whitespace is collapsed", () => { + expect(parseAnnotateArgs(" spec.md --gate ")).toEqual({ + filePath: "spec.md", + gate: true, + json: false, + }); + }); + + test("empty string produces empty result", () => { + expect(parseAnnotateArgs("")).toEqual({ + filePath: "", + gate: false, + json: false, + }); + }); + + test("nullish input is tolerated", () => { + expect(parseAnnotateArgs(undefined as unknown as string)).toEqual({ + filePath: "", + gate: false, + json: false, + }); + }); + + test("folder path with trailing slash", () => { + expect(parseAnnotateArgs("./specs/ --gate --json")).toEqual({ + filePath: "./specs/", + gate: true, + json: true, + }); + }); +}); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts new file mode 100644 index 00000000..b34351bc --- /dev/null +++ b/packages/shared/annotate-args.ts @@ -0,0 +1,29 @@ +/** + * Parse CLI-style args arriving as a single whitespace-delimited string. + * + * Extracts the `--gate` and `--json` flags (issue #570) from the remainder, + * which is treated as the target path. Leading `@` is stripped to match the + * Claude Code path-arg convention used in apps/hook/server/index.ts. + * + * Used by the OpenCode plugin and Pi extension, where the whole args string + * arrives pre-joined from the harness slash-command dispatcher. The Claude + * Code binary parses argv directly with indexOf/splice and does not use + * this helper. + */ + +export interface ParsedAnnotateArgs { + filePath: string; + gate: boolean; + json: boolean; +} + +export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { + const tokens = (raw ?? "").trim().split(/\s+/).filter(Boolean); + const gate = tokens.includes("--gate"); + const json = tokens.includes("--json"); + const filePath = tokens + .filter((t) => t !== "--gate" && t !== "--json") + .join(" ") + .replace(/^@/, ""); + return { filePath, gate, json }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index a94b401e..2dc78953 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -26,7 +26,8 @@ "./worktree": "./worktree.ts", "./html-to-markdown": "./html-to-markdown.ts", "./url-to-markdown": "./url-to-markdown.ts", - "./tour": "./tour.ts" + "./tour": "./tour.ts", + "./annotate-args": "./annotate-args.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", From f287c9416b40d9275bed7dfa97b6e9174977db14 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:22:52 -0700 Subject: [PATCH 03/26] feat(server): gate mode and /api/approve for annotate (bun + pi parity) - Add gate?: boolean to AnnotateServerOptions (Bun) and the Pi server's options type so the UI can render the Approve button. - Add approved?: boolean to the annotate decision type in both servers. - Include gate in the /api/plan response so the client knows which UX variant to render. - New /api/approve endpoint that resolves the decision with approved=true and empty feedback (mirror of /api/exit but semantically distinct). Route parity test (tests/parity/route-parity.test.ts) stays green. Part of #570. For provenance purposes, this commit was AI assisted. --- apps/pi-extension/server/serverAnnotate.ts | 10 +++++++++- packages/server/annotate.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 617d474c..75e3e9ad 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -28,7 +28,7 @@ export interface AnnotateServerResult { port: number; portSource: "env" | "remote-default" | "random"; url: string; - waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean }>; + waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean }>; stop: () => void; } @@ -43,6 +43,7 @@ export async function startAnnotateServer(options: { shareBaseUrl?: string; pasteApiUrl?: string; sourceInfo?: string; + gate?: boolean; }): Promise { const gitUser = detectGitUser(); const sharingEnabled = @@ -56,11 +57,13 @@ export async function startAnnotateServer(options: { feedback: string; annotations: unknown[]; exit?: boolean; + approved?: boolean; }) => void; const decisionPromise = new Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; + approved?: boolean; }>((r) => { resolveDecision = r; }); @@ -89,6 +92,7 @@ export async function startAnnotateServer(options: { mode: options.mode || "annotate", filePath: options.filePath, sourceInfo: options.sourceInfo, + gate: options.gate ?? false, sharingEnabled, shareBaseUrl, pasteApiUrl, @@ -135,6 +139,10 @@ export async function startAnnotateServer(options: { deleteDraft(draftKey); resolveDecision({ feedback: "", annotations: [], exit: true }); json(res, { ok: true }); + } else if (url.pathname === "/api/approve" && req.method === "POST") { + deleteDraft(draftKey); + resolveDecision({ feedback: "", annotations: [], approved: true }); + json(res, { ok: true }); } else if (url.pathname === "/api/feedback" && req.method === "POST") { try { const body = await parseBody(req); diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 0daa9fcf..2370a35c 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -50,6 +50,8 @@ export interface AnnotateServerOptions { pasteApiUrl?: string; /** Source attribution: original URL or filename (e.g. "https://..." or "index.html") */ sourceInfo?: string; + /** Enable review-gate UX: adds an Approve button alongside Close/Send Annotations (#570) */ + gate?: boolean; /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; } @@ -66,6 +68,7 @@ export interface AnnotateServerResult { feedback: string; annotations: unknown[]; exit?: boolean; + approved?: boolean; }>; /** Stop the server */ stop: () => void; @@ -98,6 +101,7 @@ export async function startAnnotateServer( sharingEnabled = true, shareBaseUrl, pasteApiUrl, + gate = false, onReady, } = options; @@ -120,11 +124,13 @@ export async function startAnnotateServer( feedback: string; annotations: unknown[]; exit?: boolean; + approved?: boolean; }) => void; const decisionPromise = new Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; + approved?: boolean; }>((resolve) => { resolveDecision = resolve; }); @@ -149,6 +155,7 @@ export async function startAnnotateServer( mode, filePath, sourceInfo, + gate, sharingEnabled, shareBaseUrl, pasteApiUrl, @@ -237,6 +244,13 @@ export async function startAnnotateServer( return Response.json({ ok: true }); } + // API: Approve the annotation session (review-gate UX, #570) + if (url.pathname === "/api/approve" && req.method === "POST") { + deleteDraft(draftKey); + resolveDecision({ feedback: "", annotations: [], approved: true }); + return Response.json({ ok: true }); + } + // API: Submit annotation feedback if (url.pathname === "/api/feedback" && req.method === "POST") { try { From 384e5d84afb574a8600b77fd46780100b4fd53ec Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:24:59 -0700 Subject: [PATCH 04/26] feat(editor): three-button annotate header when gate is enabled When the server sets gate=true on /api/plan (#570), render a third Approve button alongside Close + Send Annotations. New handler POSTs to /api/approve. Completion overlay copy differentiates 'Approved' (annotate-mode approve) from 'Plan Approved' (plan-mode approve). For provenance purposes, this commit was AI assisted. --- packages/editor/App.tsx | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 5ade5dca..d568b9d0 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -147,6 +147,7 @@ const App: React.FC = () => { const [isWSL, setIsWSL] = useState(false); const [globalAttachments, setGlobalAttachments] = useState([]); const [annotateMode, setAnnotateMode] = useState(false); + const [gate, setGate] = useState(false); const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null); const [sourceInfo, setSourceInfo] = useState(); const [sourceFilePath, setSourceFilePath] = useState(); @@ -642,7 +643,7 @@ const App: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { + .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; gate?: boolean; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable @@ -663,6 +664,7 @@ const App: React.FC = () => { setIsApiMode(true); if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder') { setAnnotateMode(true); + setGate(data.gate ?? false); } if (data.mode === 'annotate-folder') { sidebar.open('files'); @@ -989,6 +991,17 @@ const App: React.FC = () => { } }; + // Annotate gate-mode handler — approves the artifact without feedback (#570) + const handleAnnotateApprove = async () => { + setIsSubmitting(true); + try { + await fetch('/api/approve', { method: 'POST' }); + setSubmitted('approved'); + } catch { + setIsSubmitting(false); + } + }; + // Exit annotation session without sending feedback const handleAnnotateExit = useCallback(async () => { setIsExiting(true); @@ -1422,7 +1435,8 @@ const App: React.FC = () => { {isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( <> {annotateMode ? ( - // Annotate mode: Close always visible, Send Annotations when annotations exist + // Annotate mode: Close always visible, Send Annotations when annotations exist, + // Approve only when gate (review) mode is enabled (#570). <> (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 || globalAttachments.length > 0) ? setShowExitWarning(true) : handleAnnotateExit()} @@ -1438,6 +1452,14 @@ const App: React.FC = () => { title="Send Annotations" /> )} + {gate && ( + + )} ) : ( // Plan mode: Send Feedback @@ -1944,8 +1966,9 @@ const App: React.FC = () => { title={ archive.archiveMode ? 'Archive Closed' : submitted === 'exited' ? 'Session Closed' - : submitted === 'approved' ? 'Plan Approved' - : annotateMode ? 'Annotations Sent' + : submitted === 'approved' + ? (annotateMode ? 'Approved' : 'Plan Approved') + : annotateMode ? 'Annotations Sent' : 'Feedback Sent' } subtitle={ @@ -1954,7 +1977,9 @@ const App: React.FC = () => { : archive.archiveMode ? 'You can reopen with plannotator archive.' : submitted === 'approved' - ? `${agentName} will proceed with the implementation.` + ? (annotateMode + ? `${agentName} will proceed.` + : `${agentName} will proceed with the implementation.`) : annotateMode ? `${agentName} will address your annotations on the ${annotateSource === 'message' ? 'message' : annotateSource === 'folder' ? 'files' : 'file'}.` : `${agentName} will revise the plan based on your annotations.` From 94b0144447ab43f72a0a98e5225cca10d26055a9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:27:51 -0700 Subject: [PATCH 05/26] feat(hook): --gate and --json flags for annotate / annotate-last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --gate and --json argv parsing next to --no-jina. Both are orthogonal; matrix is documented in #570 and in the draft PR body. - Thread gate through all three startAnnotateServer call sites (annotate, annotate-last, copilot-cli annotate-last). - Replace three identical stdout blocks with a shared emitAnnotateOutcome helper that implements the 4-case matrix. - Drop the "Annotation session closed without feedback." line — Close now emits truly empty stdout so naive PostToolUse hooks (empty = allow, non-empty = block) work out of the box. - Update plannotator-annotate.md and plannotator-last.md templates so the agent handles empty stdout gracefully. For provenance purposes, this commit was AI assisted. --- apps/hook/commands/plannotator-annotate.md | 4 +- apps/hook/commands/plannotator-last.md | 4 +- apps/hook/server/index.ts | 53 ++++++++++++++++------ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/apps/hook/commands/plannotator-annotate.md b/apps/hook/commands/plannotator-annotate.md index 218e270a..67f827ed 100644 --- a/apps/hook/commands/plannotator-annotate.md +++ b/apps/hook/commands/plannotator-annotate.md @@ -10,4 +10,6 @@ disable-model-invocation: true ## Your task -Address the annotation feedback above. The user has reviewed the markdown file(s) and provided specific annotations and comments. +If the output above is empty, the user closed the annotation session without providing feedback. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. + +Otherwise, address the annotation feedback above. The user has reviewed the markdown file(s) and provided specific annotations and comments. diff --git a/apps/hook/commands/plannotator-last.md b/apps/hook/commands/plannotator-last.md index f8f5d8ad..64c415a0 100644 --- a/apps/hook/commands/plannotator-last.md +++ b/apps/hook/commands/plannotator-last.md @@ -10,4 +10,6 @@ disable-model-invocation: true ## Your task -Address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments. +If the output above is empty, the user closed the annotation session without providing feedback. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. + +Otherwise, address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments. diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 26e9c986..7db2d261 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -124,6 +124,38 @@ const noJinaIdx = args.indexOf("--no-jina"); const cliNoJina = noJinaIdx !== -1; if (cliNoJina) args.splice(noJinaIdx, 1); +// Annotate review-gate flags (#570): --gate adds an Approve button, +// --json switches stdout to structured decision output. +const gateIdx = args.indexOf("--gate"); +const gateFlag = gateIdx !== -1; +if (gateFlag) args.splice(gateIdx, 1); +const jsonIdx = args.indexOf("--json"); +const jsonFlag = jsonIdx !== -1; +if (jsonFlag) args.splice(jsonIdx, 1); + +// Stdout matrix for annotate / annotate-last / copilot annotate-last (#570). +// Approve and Close both emit empty stdout in plaintext mode so naive PostToolUse +// and Stop hooks (empty = allow, non-empty = block) work without parsing. +// --json switches to structured output across all three decisions. +function emitAnnotateOutcome(result: { + feedback: string; + exit?: boolean; + approved?: boolean; +}): void { + if (jsonFlag) { + if (result.approved) { + console.log(JSON.stringify({ decision: "approved" })); + } else if (result.exit) { + console.log(JSON.stringify({ decision: "dismissed" })); + } else { + console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); + } + return; + } + if (result.approved || result.exit) return; // empty stdout + if (result.feedback) console.log(result.feedback); +} + if (isTopLevelHelpInvocation(args)) { console.log(formatTopLevelHelp()); process.exit(0); @@ -590,6 +622,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, + gate: gateFlag, htmlContent: planHtmlContent, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -622,11 +655,7 @@ if (args[0] === "sessions") { server.stop(); // Output feedback (captured by slash command) - if (result.exit) { - console.log("Annotation session closed without feedback."); - } else { - console.log(result.feedback || "No feedback provided."); - } + emitAnnotateOutcome(result); process.exit(0); } else if (args[0] === "annotate-last" || args[0] === "last") { @@ -724,6 +753,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, + gate: gateFlag, htmlContent: planHtmlContent, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -750,11 +780,7 @@ if (args[0] === "sessions") { server.stop(); - if (result.exit) { - console.log("Annotation session closed without feedback."); - } else { - console.log(result.feedback || "No feedback provided."); - } + emitAnnotateOutcome(result); process.exit(0); } else if (args[0] === "archive") { @@ -915,6 +941,7 @@ if (args[0] === "sessions") { mode: "annotate-last", sharingEnabled, shareBaseUrl, + gate: gateFlag, htmlContent: planHtmlContent, onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -939,11 +966,7 @@ if (args[0] === "sessions") { await Bun.sleep(1500); server.stop(); - if (result.exit) { - console.log("Annotation session closed without feedback."); - } else { - console.log(result.feedback || "No feedback provided."); - } + emitAnnotateOutcome(result); process.exit(0); } else if (args[0] === "improve-context") { From 87e651afb36d0ef863b0c1560c21c4d907ae18e4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:29:27 -0700 Subject: [PATCH 06/26] feat(opencode): --gate passthrough for annotate / annotate-last Both handlers now call parseAnnotateArgs on the slash-command args string to separate --gate and --json from the target path. Gate flag threads through to startAnnotateServer so the editor renders the three-button UX on request. --json is silently accepted: OpenCode writes back to the session via client.session.prompt, not stdout, so there's no channel for JSON. Accepting it without error keeps hook recipes portable across harnesses. Session-injection logic now treats approved the same as exit: skip the prompt injection. Annotate feedback still injects as before. Part of #570. For provenance purposes, this commit was AI assisted. --- apps/opencode-plugin/commands.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 0618e41f..19d427b6 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -24,6 +24,7 @@ import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator 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"; +import { parseAnnotateArgs } from "@plannotator/shared/annotate-args"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { statSync } from "fs"; import path from "path"; @@ -152,17 +153,17 @@ export async function handleAnnotateCommand( const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps; // @ts-ignore - Event properties contain arguments - let filePath = event.properties?.arguments || event.arguments || ""; + const rawArgs = event.properties?.arguments || event.arguments || ""; + // #570: split --gate / --json out of the args; rest is the file path. + // --json is accepted silently (OpenCode writes to session, not stdout). + // parseAnnotateArgs strips leading @ on filePath (reference-mode convention). + const { filePath, gate } = parseAnnotateArgs(rawArgs); if (!filePath) { - client.app.log({ level: "error", message: "Usage: /plannotator-annotate " }); + client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--gate] [--json]" }); return; } - if (filePath.startsWith("@")) { - filePath = filePath.slice(1); - } - let markdown: string; let absolutePath: string; let folderPath: string | undefined; @@ -256,6 +257,7 @@ export async function handleAnnotateCommand( sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), + gate, htmlContent, onReady: handleAnnotateServerReady, }); @@ -264,7 +266,8 @@ export async function handleAnnotateCommand( await Bun.sleep(1500); server.stop(); - if (result.exit) { + // Both exit and approve are "no-op for the agent" — skip session injection. + if (result.exit || result.approved) { return; } @@ -301,6 +304,11 @@ export async function handleAnnotateLastCommand( ): Promise { const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; + // @ts-ignore - Event properties contain arguments + const rawArgs = event.properties?.arguments || event.arguments || ""; + // #570: support --gate on /plannotator-last (Stop-hook review-gate pattern). + const { gate } = parseAnnotateArgs(rawArgs); + // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; if (!sessionId) { @@ -346,6 +354,7 @@ export async function handleAnnotateLastCommand( sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), + gate, htmlContent, onReady: handleAnnotateServerReady, }); @@ -354,7 +363,8 @@ export async function handleAnnotateLastCommand( await Bun.sleep(1500); server.stop(); - if (result.exit) { + // Both exit and approve signal "don't inject feedback" — return null. + if (result.exit || result.approved) { return null; } From 8d17c478f81c93302c887fc39409e10e35a10038 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:33:16 -0700 Subject: [PATCH 07/26] feat(pi): --gate passthrough for annotate / annotate-last - vendor.sh: add annotate-args to the list of vendored shared modules so Pi gets its own generated/annotate-args.ts at build time. - index.ts: parseAnnotateArgs splits --gate / --json from the path on both /plannotator-annotate and /plannotator-last. --json is silently accepted (Pi writes via sendUserMessage, not stdout). Both handlers branch on result.approved (notify only) vs result.exit vs result.feedback (inject as user message). - plannotator-browser.ts: openMarkdownAnnotation and openLastMessageAnnotation take an optional `gate` param and return approved?: boolean in the decision. - plannotator-events.ts: extend PlannotatorAnnotatePayload with gate? and PlannotatorAnnotationResult with approved? for the outbound third-party consumer API. Internal event handlers thread gate through to openMarkdownAnnotation / openLastMessageAnnotation. Part of #570. For provenance purposes, this commit was AI assisted. --- apps/pi-extension/index.ts | 24 +++++++++++++++++------- apps/pi-extension/plannotator-browser.ts | 9 ++++++--- apps/pi-extension/plannotator-events.ts | 8 +++++++- apps/pi-extension/vendor.sh | 2 +- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 220e5b25..32c64c99 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -37,6 +37,7 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js"; import { htmlToMarkdown } from "./generated/html-to-markdown.js"; import { urlToMarkdown } from "./generated/url-to-markdown.js"; import { loadConfig, resolveUseJina } from "./generated/config.js"; +import { parseAnnotateArgs } from "./generated/annotate-args.js"; import { getLastAssistantMessageText, hasPlanBrowserHtml, @@ -337,9 +338,11 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-annotate", { description: "Open markdown file or folder in annotation UI", handler: async (args, ctx) => { - const filePath = args?.trim(); + // #570: split --gate / --json from the path. --json is silently + // accepted (Pi writes back via sendUserMessage, not stdout). + const { filePath, gate } = parseAnnotateArgs(args ?? ""); if (!filePath) { - ctx.ui.notify("Usage: /plannotator-annotate ", "error"); + ctx.ui.notify("Usage: /plannotator-annotate [--gate] [--json]", "error"); return; } if (!hasPlanBrowserHtml()) { @@ -413,8 +416,10 @@ export default function plannotator(pi: ExtensionAPI): void { } try { - const result = await openMarkdownAnnotation(ctx, absolutePath, markdown, mode ?? "annotate", folderPath, sourceInfo); - if (result.exit) { + const result = await openMarkdownAnnotation(ctx, absolutePath, markdown, mode ?? "annotate", folderPath, sourceInfo, gate); + if (result.approved) { + ctx.ui.notify("Annotation approved.", "info"); + } else if (result.exit) { ctx.ui.notify("Annotation session closed.", "info"); } else if (result.feedback) { const header = isFolder @@ -437,7 +442,10 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-last", { description: "Annotate the last assistant message", - handler: async (_args, ctx) => { + handler: async (args, ctx) => { + // #570: support --gate on /plannotator-last for Stop-hook review gate. + const { gate } = parseAnnotateArgs(args ?? ""); + if (!hasPlanBrowserHtml()) { ctx.ui.notify( "Annotation UI not available. Run 'bun run build' in the pi-extension directory.", @@ -455,8 +463,10 @@ export default function plannotator(pi: ExtensionAPI): void { ctx.ui.notify("Opening annotation UI for last message...", "info"); try { - const result = await openLastMessageAnnotation(ctx, lastText); - if (result.exit) { + const result = await openLastMessageAnnotation(ctx, lastText, gate); + if (result.approved) { + ctx.ui.notify("Message approved.", "info"); + } else if (result.exit) { ctx.ui.notify("Annotation session closed.", "info"); } else if (result.feedback) { pi.sendUserMessage( diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 4032959f..db4a7a4b 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -370,7 +370,8 @@ export async function openMarkdownAnnotation( mode: AnnotateMode, folderPath?: string, sourceInfo?: string, -): Promise<{ feedback: string; exit?: boolean }> { + gate?: boolean, +): Promise<{ feedback: string; exit?: boolean; approved?: boolean }> { if (!ctx.hasUI || !planHtmlContent) { throw new Error("Plannotator annotation browser is unavailable in this session."); } @@ -394,6 +395,7 @@ export async function openMarkdownAnnotation( mode, folderPath, sourceInfo, + gate, htmlContent: planHtmlContent, sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, @@ -406,8 +408,9 @@ export async function openMarkdownAnnotation( export async function openLastMessageAnnotation( ctx: ExtensionContext, lastText: string, -): Promise<{ feedback: string; exit?: boolean }> { - return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last"); + gate?: boolean, +): Promise<{ feedback: string; exit?: boolean; approved?: boolean }> { + return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last", undefined, undefined, gate); } export async function openArchiveBrowserAction( diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index defe87d4..7688276b 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -100,10 +100,14 @@ export interface PlannotatorAnnotatePayload { markdown?: string; mode?: "annotate" | "annotate-folder" | "annotate-last"; folderPath?: string; + /** Enable review-gate UX (Approve / Annotate / Close), #570 */ + gate?: boolean; } export interface PlannotatorAnnotationResult { feedback: string; + /** True when the reviewer clicked Approve in review-gate mode, #570 */ + approved?: boolean; } export interface PlannotatorArchivePayload { @@ -272,6 +276,8 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { payload.markdown ?? "", payload.mode ?? "annotate", payload.folderPath, + undefined, + payload.gate, ); request.respond({ status: "handled", result }); return; @@ -283,7 +289,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { request.respond({ status: "unavailable", error: "No assistant message found in session." }); return; } - const result = await openLastMessageAnnotation(ctx, lastText); + const result = await openLastMessageAnnotation(ctx, lastText, payload?.gate); request.respond({ status: "handled", result }); return; } diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 51dbc426..bc95cc21 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -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-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown tour; do +for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown tour annotate-args; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done From 47a9d24d61790b3e8e29b6ec79cea2f4b1d02adc Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:35:13 -0700 Subject: [PATCH 08/26] docs(annotate): document --gate, --json, and hook recipes (#570) - Annotate: full Flags section with stdout matrix + key-property callout + note on --json semantics across harnesses. Server API table gains /api/approve. - Annotate-last: short Flags section pointing back to annotate's matrix + Stop-hook usage teaser. - New guide: hook-integration.md with two ready-to-copy recipes (PostToolUse spec gate + Stop-hook turn gate) in both plaintext and --json variants, plus OpenCode/Pi notes and gotchas. For provenance purposes, this commit was AI assisted. --- .../content/docs/commands/annotate-last.md | 12 ++ .../src/content/docs/commands/annotate.md | 42 +++++- .../content/docs/guides/hook-integration.md | 123 ++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 apps/marketing/src/content/docs/guides/hook-integration.md diff --git a/apps/marketing/src/content/docs/commands/annotate-last.md b/apps/marketing/src/content/docs/commands/annotate-last.md index 315f7734..aef48995 100644 --- a/apps/marketing/src/content/docs/commands/annotate-last.md +++ b/apps/marketing/src/content/docs/commands/annotate-last.md @@ -73,6 +73,18 @@ The annotation UI in `annotate-last` mode works the same as `/plannotator-annota - Completion screen says "annotations on the message" - Feedback export is titled "Message Feedback" instead of "Plan Feedback" +## Flags + +`plannotator annotate-last` accepts the same `--gate` and `--json` flags as `plannotator annotate`. See [Annotate → Flags](/docs/commands/annotate/#flags) for the full matrix. + +The common use case for `--gate` on annotate-last is a turn-by-turn review gate wired to a Stop hook: + +```bash +plannotator annotate-last --gate +``` + +Paired with a Claude Code `Stop` hook, this pauses every agent turn for human review. Approve lets the turn end; Send Annotations re-prompts the agent with feedback. See [Hook integration recipes](/docs/guides/hook-integration/). + ## Server API The annotate-last mode reuses the same annotate server endpoints. See the [annotate docs](/docs/commands/annotate/#server-api). diff --git a/apps/marketing/src/content/docs/commands/annotate.md b/apps/marketing/src/content/docs/commands/annotate.md index a7b82f36..c80b1c2a 100644 --- a/apps/marketing/src/content/docs/commands/annotate.md +++ b/apps/marketing/src/content/docs/commands/annotate.md @@ -94,13 +94,50 @@ When annotating an HTML file or URL (not plain markdown), a small badge appears The annotation UI in annotate mode works the same as plan review, with a few changes: -- The "Approve" button is hidden (there's nothing to approve) +- The "Approve" button is hidden by default (there's nothing to approve for most use cases). Pass `--gate` to enable it as a review decision. - "Send Feedback" becomes **"Send Annotations"** - `Cmd/Ctrl+Enter` sends annotations instead of approving - The completion screen says "Annotations Sent" instead of "Plan Approved" All annotation types work identically: deletions, replacements, comments, insertions, global comments, and image attachments. +## Flags + +Two opt-in flags turn annotate into a review gate for hook integrations (spec-driven frameworks, turn-by-turn review, and so on). They are orthogonal: you can use either alone or combine them. + +### `--gate` + +Adds a third **Approve** button to the UI. The reviewer now has three exits: + +- **Approve** — the artifact looks good; the agent should proceed. +- **Send Annotations** — changes requested; feedback goes back to the agent. +- **Close** — dismissed without deciding. + +### `--json` + +Switches stdout to a structured decision object so hooks can route programmatically: + +```json +{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." } +``` + +`feedback` is only present when `decision === "annotated"`. + +### Stdout matrix + +| Flags | UX | Approve | Close | Send Annotations | +|---|---|---|---|---| +| *(none)* | 2-button | n/a | empty | feedback (plaintext) | +| `--gate` | 3-button | empty | empty | feedback (plaintext) | +| `--json` | 2-button | n/a | `{"decision":"dismissed"}` | `{"decision":"annotated","feedback":"..."}` | +| `--gate --json` | 3-button | `{"decision":"approved"}` | `{"decision":"dismissed"}` | `{"decision":"annotated","feedback":"..."}` | + +**Key property:** in `--gate` mode without `--json`, Approve and Close both emit empty stdout, so naive PostToolUse / Stop hooks (empty = allow, non-empty = block) work with no parsing. Only Send Annotations blocks. Add `--json` when you need explicit approved-vs-dismissed telemetry. + +On OpenCode and Pi, `--json` is silently accepted because those harnesses write back into the session directly rather than via stdout. The `--gate` flag behaves identically across all three harnesses. + +See [Hook integration recipes](/docs/guides/hook-integration/) for ready-to-use PostToolUse and Stop hook examples. + ## Feedback format When you send annotations, they're exported as structured markdown: @@ -128,8 +165,9 @@ The agent receives this and can act on each annotation. | Endpoint | Method | Purpose | |----------|--------|---------| -| `/api/plan` | GET | Returns `{ plan, mode: "annotate", filePath, sourceInfo }` | +| `/api/plan` | GET | Returns `{ plan, mode: "annotate", filePath, sourceInfo, gate }` | | `/api/feedback` | POST | Submit annotations | +| `/api/approve` | POST | Approve without feedback (`--gate` UX) | | `/api/exit` | POST | Close session without feedback | | `/api/image` | GET | Serve image by path | | `/api/upload` | POST | Upload image attachment | diff --git a/apps/marketing/src/content/docs/guides/hook-integration.md b/apps/marketing/src/content/docs/guides/hook-integration.md new file mode 100644 index 00000000..798b34bb --- /dev/null +++ b/apps/marketing/src/content/docs/guides/hook-integration.md @@ -0,0 +1,123 @@ +--- +title: "Hook Integration" +description: "Use plannotator annotate and annotate-last as review gates from agent hooks — spec-driven workflows, turn-by-turn review, and more." +sidebar: + order: 27 +section: "Guides" +--- + +The `--gate` and `--json` flags on `plannotator annotate` and `plannotator annotate-last` turn annotation into a structured review decision. This guide shows how to wire them into agent hooks so a human can gate the agent at specific points in a workflow. + +See [Annotate → Flags](/docs/commands/annotate/#flags) for the full stdout matrix. The short version: + +- `--gate` adds a three-button UX (Approve / Send Annotations / Close). +- Without `--json`: Approve and Close emit empty stdout; Send Annotations emits the feedback markdown. Matches Claude Code's natural PostToolUse / Stop hook convention (empty = allow, non-empty = block). +- With `--json`: every decision emits a structured `{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." }` object. + +## Recipe 1: PostToolUse spec gate + +Spec-driven frameworks (spec-kit, kiro, openspec) generate multiple markdown artifacts per feature — spec.md, plan.md, tasks.md, and so on — each needing human review before the agent builds from it. A PostToolUse hook on Write turns plannotator into a reviewer in the loop. + +### Plaintext (naive) + +Add to `.claude/hooks.json`: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "plannotator annotate \"$CLAUDE_TOOL_INPUT_file_path\" --gate", + "timeout": 345600 + } + ] + } + ] + } +} +``` + +Behavior: + +- **Approve** → empty stdout → Claude Code proceeds as normal. +- **Send Annotations** → feedback on stdout → Claude Code blocks the turn with the feedback as the reason. +- **Close** → empty stdout → Claude Code proceeds. + +No parsing needed. Approve and Close are equivalent from the agent's perspective; the human distinction is preserved in the UI. + +### Structured (`--json`) + +If you want to route on decision type explicitly — for example, only re-prompt on `annotated` and log `approved` vs `dismissed` separately — pipe through `jq` or a small shell wrapper: + +```bash +#!/usr/bin/env bash +# .claude/hooks/spec-gate.sh +result=$(plannotator annotate "$CLAUDE_TOOL_INPUT_file_path" --gate --json) +decision=$(echo "$result" | jq -r '.decision') +feedback=$(echo "$result" | jq -r '.feedback // ""') + +case "$decision" in + approved|dismissed) exit 0 ;; + annotated) echo "$feedback" ; exit 2 ;; +esac +``` + +## Recipe 2: Stop-hook turn gate + +Wire `annotate-last` to Claude Code's Stop hook to pause every agent turn for human review. + +### Plaintext + +```json +{ + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "plannotator annotate-last --gate", + "timeout": 345600 + } + ] + } + ] + } +} +``` + +Behavior: + +- **Approve** → empty stdout → the turn ends normally, control returns to the user. +- **Send Annotations** → feedback on stdout → Claude Code re-prompts with the feedback. +- **Close** → empty stdout → turn ends. + +### Structured + +Same pattern as the PostToolUse recipe — pipe `--gate --json` through a shell wrapper if you want distinct handling per decision. + +## OpenCode and Pi + +The same `--gate` flag works in OpenCode's `/plannotator-annotate` and Pi's `/plannotator-annotate` slash commands: + +``` +/plannotator-annotate spec.md --gate +``` + +On those harnesses there is no stdout channel back to the agent — the plugin writes back via `session.prompt` (OpenCode) or `sendUserMessage` (Pi). Approve and Close both result in no session injection; Send Annotations injects the feedback. `--json` is accepted silently on these harnesses so recipes stay portable. + +Third-party Pi or OpenCode plugins that want explicit decision routing can read `approved` directly from the server's decision object: + +- OpenCode plugin: `server.waitForDecision()` returns `{ feedback, annotations, exit?, approved? }`. +- Pi: `openMarkdownAnnotation()` and `openLastMessageAnnotation()` return `{ feedback, exit?, approved? }`. + +## Notes + +- Exit code is always `0`. Gate decisions are signaled via stdout, not exit code. +- Folder annotation with `--gate` applies one decision to the whole session (not per-file). The user navigates the file browser inside the UI, annotates across files, and submits once. +- The `--gate` UX is fully opt-in. Users running `/plannotator-annotate README.md` interactively without the flag still see the 2-button experience. From 61193eab6bf3b041f1f5a18267f09db6abd6c01e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 07:59:43 -0700 Subject: [PATCH 09/26] fix(annotate): route Cmd/Enter to approve in gate-mode + clarify JSON recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two self-review fixes: 1. editor: when --gate is on and the user presses Cmd/Ctrl+Enter with no annotations, call handleAnnotateApprove instead of handleAnnotateFeedback. Previously this would POST empty annotations, which produces the "User reviewed the document and has no feedback." boilerplate string on stdout — blocking naive PostToolUse hooks on a meaningless signal. Now the keyboard shortcut matches the visible primary action (Approve button) in the no-annotations case. 2. docs: the --json hook recipe previously used `exit 2` to signal "block" for the annotated case, but Claude Code PostToolUse treats stderr (not stdout) as the block-reason channel on exit 2. The binary's native plaintext --gate mode blocks via stdout + exit 0, and the --json recipe should mirror that contract exactly. Rewrote the case handler to always exit 0 and signal via stdout presence. For provenance purposes, this commit was AI assisted. --- .../content/docs/guides/hook-integration.md | 11 +++++++++-- packages/editor/App.tsx | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/marketing/src/content/docs/guides/hook-integration.md b/apps/marketing/src/content/docs/guides/hook-integration.md index 798b34bb..d1a5b4a7 100644 --- a/apps/marketing/src/content/docs/guides/hook-integration.md +++ b/apps/marketing/src/content/docs/guides/hook-integration.md @@ -61,11 +61,18 @@ decision=$(echo "$result" | jq -r '.decision') feedback=$(echo "$result" | jq -r '.feedback // ""') case "$decision" in - approved|dismissed) exit 0 ;; - annotated) echo "$feedback" ; exit 2 ;; + approved|dismissed) + # empty stdout — hook passes through, agent proceeds + ;; + annotated) + # emit feedback on stdout so the hook blocks with it as the reason + echo "$feedback" + ;; esac ``` +Exit code stays `0` for all three branches; signaling happens via stdout (empty = pass, non-empty = block). This mirrors the `--gate`-without-`--json` mode exactly — JSON just gives you a parsed decision for logging or conditional routing without changing the block contract. + ## Recipe 2: Stop-hook turn gate Wire `annotate-last` to Claude Code's Stop hook to pause every agent turn for human review. diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index d568b9d0..c98f8205 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1042,8 +1042,23 @@ const App: React.FC = () => { e.preventDefault(); - // Annotate mode: always send feedback (empty = "no feedback" message) + // Annotate mode: gate-enabled + no annotations → approve (empty stdout). + // Otherwise: send feedback. if (annotateMode) { + if (gate) { + const docAnnotations = linkedDocHook.getDocAnnotations(); + const hasDocAnnotations = Array.from(docAnnotations.values()).some( + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + ); + const hasAnyAnnotations = allAnnotations.length > 0 + || editorAnnotations.length > 0 + || globalAttachments.length > 0 + || hasDocAnnotations; + if (!hasAnyAnnotations) { + handleAnnotateApprove(); + return; + } + } handleAnnotateFeedback(); return; } @@ -1075,6 +1090,7 @@ const App: React.FC = () => { showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode, + gate, globalAttachments.length, editorAnnotations.length, origin, getAgentWarning, ]); From ba49e86d375fe153f78cab56cf0d3373ac81f0d5 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 08:34:32 -0700 Subject: [PATCH 10/26] refactor(editor): consolidate gate-mode Approve into existing plan Approve Three parallel structures merged into their existing counterparts: 1. Remove the second render I had added inside the annotate branch. Widen the existing plan-mode render's condition from {!annotateMode} to {(!annotateMode || gate)} and branch the onClick on annotateMode. One render site, two modes. 2. Reuse the existing showExitWarning ConfirmDialog for the annotate-gate Approve guardrail. New exitWarningAction state ('close' | 'approve') carries which button opened it; dialog's onConfirm routes to handleAnnotateExit vs handleAnnotateApprove and confirmText/message swap accordingly. Single dialog serves both destructive actions. 3. Extract hasAnyAnnotations useMemo. Replaces the four-term inline check in the annotate-mode Close button, Send Annotations render, and Cmd+Enter handler. No behavior change vs the previous commit; this is pure consolidation. For provenance purposes, this commit was AI assisted. --- packages/editor/App.tsx | 78 ++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c98f8205..f984f138 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -99,6 +99,8 @@ const App: React.FC = () => { const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); const [showExitWarning, setShowExitWarning] = useState(false); + // When the warning dialog confirms, route to the handler matching the button that opened it. + const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); const [showAgentWarning, setShowAgentWarning] = useState(false); const [agentWarningMessage, setAgentWarningMessage] = useState(''); const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768); @@ -541,6 +543,15 @@ const App: React.FC = () => { // Plan diff state — memoize filtered annotation lists to avoid new references per render const diffAnnotations = useMemo(() => allAnnotations.filter(a => !!a.diffContext), [allAnnotations]); const viewerAnnotations = useMemo(() => allAnnotations.filter(a => !a.diffContext), [allAnnotations]); + // Any-annotations flag used by Close/Approve/Send guards. Consolidates the + // four-term check that was inlined across the annotate-mode header + keyboard paths. + const hasAnyAnnotations = useMemo( + () => allAnnotations.length > 0 + || editorAnnotations.length > 0 + || linkedDocHook.docAnnotationCount > 0 + || globalAttachments.length > 0, + [allAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length], + ); // URL-based sharing const { @@ -1045,19 +1056,9 @@ const App: React.FC = () => { // Annotate mode: gate-enabled + no annotations → approve (empty stdout). // Otherwise: send feedback. if (annotateMode) { - if (gate) { - const docAnnotations = linkedDocHook.getDocAnnotations(); - const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 - ); - const hasAnyAnnotations = allAnnotations.length > 0 - || editorAnnotations.length > 0 - || globalAttachments.length > 0 - || hasDocAnnotations; - if (!hasAnyAnnotations) { - handleAnnotateApprove(); - return; - } + if (gate && !hasAnyAnnotations) { + handleAnnotateApprove(); + return; } handleAnnotateFeedback(); return; @@ -1090,7 +1091,7 @@ const App: React.FC = () => { showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode, - gate, globalAttachments.length, editorAnnotations.length, + gate, hasAnyAnnotations, origin, getAgentWarning, ]); @@ -1455,11 +1456,18 @@ const App: React.FC = () => { // Approve only when gate (review) mode is enabled (#570). <> (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 || globalAttachments.length > 0) ? setShowExitWarning(true) : handleAnnotateExit()} + onClick={() => { + if (hasAnyAnnotations) { + setExitWarningAction('close'); + setShowExitWarning(true); + } else { + handleAnnotateExit(); + } + }} disabled={isSubmitting || isExiting} isLoading={isExiting} /> - {(allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 || globalAttachments.length > 0) && ( + {hasAnyAnnotations && ( { title="Send Annotations" /> )} - {gate && ( - - )} ) : ( // Plan mode: Send Feedback @@ -1498,9 +1498,21 @@ const App: React.FC = () => { /> )} - {!annotateMode &&
+ {(!annotateMode || gate) &&
{ + // Annotate gate mode: guard against dropping annotations via the existing + // showExitWarning dialog (routed via exitWarningAction='approve'). + if (annotateMode) { + if (hasAnyAnnotations) { + setExitWarningAction('approve'); + setShowExitWarning(true); + return; + } + handleAnnotateApprove(); + return; + } + // Plan mode: existing Claude-Code / OpenCode guards. if (origin === 'claude-code' && allAnnotations.length > 0) { setShowClaudeCodeWarning(true); return; @@ -1515,11 +1527,12 @@ const App: React.FC = () => { } handleApprove(); }} - disabled={isSubmitting} + disabled={isSubmitting || (annotateMode && isExiting)} isLoading={isSubmitting} - dimmed={(origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0} + dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0} + title={annotateMode ? 'Approve — no changes requested' : undefined} /> - {(origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0 && ( + {!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0 && (
@@ -1917,18 +1930,19 @@ const App: React.FC = () => { showCancel /> - {/* Exit with annotations warning dialog */} + {/* Unsaved-annotations warning dialog — reused by Close and (in gate mode) Approve */} setShowExitWarning(false)} onConfirm={() => { setShowExitWarning(false); - handleAnnotateExit(); + if (exitWarningAction === 'approve') handleAnnotateApprove(); + else handleAnnotateExit(); }} title="Annotations Won't Be Sent" - message={<>You have {allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length} annotation{(allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length) !== 1 ? 's' : ''} that will be lost if you close.} + message={<>You have {allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length} annotation{(allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length) !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.} subMessage="To send your annotations, use Send Annotations instead." - confirmText="Close Anyway" + confirmText={exitWarningAction === 'approve' ? 'Approve Anyway' : 'Close Anyway'} cancelText="Cancel" variant="warning" showCancel From b8821ad38ee6bb16843082cf875c51fd0fb08175 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 08:43:01 -0700 Subject: [PATCH 11/26] fix(hook): forward $ARGUMENTS from /plannotator-last template The Claude Code slash command template for plannotator-last was invoking `plannotator annotate-last` without forwarding the user's arguments, so `/plannotator-last --gate` and `/plannotator-last --json` were silently ignored on Claude Code even though the binary parses both flags, the annotate-last binary path threads gate through to the server, and the docs explicitly cover the Stop-hook recipe. Parity with /plannotator-annotate (forwards $ARGUMENTS) and /plannotator-review (forwards $ARGUMENTS). One-line fix. For provenance purposes, this commit was AI assisted. --- apps/hook/commands/plannotator-last.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hook/commands/plannotator-last.md b/apps/hook/commands/plannotator-last.md index 64c415a0..6c132828 100644 --- a/apps/hook/commands/plannotator-last.md +++ b/apps/hook/commands/plannotator-last.md @@ -6,7 +6,7 @@ disable-model-invocation: true ## Message Annotations -!`plannotator annotate-last` +!`plannotator annotate-last $ARGUMENTS` ## Your task From a8b445801b7b7cab91d47727bc12d31b1ca3eee8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 09:07:21 -0700 Subject: [PATCH 12/26] fix(hook): teach annotate templates to handle --json decisions When a user passes --json via /plannotator-annotate or /plannotator-last, the binary emits structured decision objects like {"decision":"approved"} or {"decision":"dismissed"}. The existing template prose only distinguished empty vs non-empty stdout, so approved and dismissed JSON markers were falling into the "address the feedback" branch and confusing the agent. Both templates now recognize approved/dismissed JSON as equivalent to empty stdout ("no changes requested, stop"), and tell the agent to pull the feedback field when the decision is annotated. For provenance purposes, this commit was AI assisted. --- apps/hook/commands/plannotator-annotate.md | 4 ++-- apps/hook/commands/plannotator-last.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/hook/commands/plannotator-annotate.md b/apps/hook/commands/plannotator-annotate.md index 67f827ed..4c3b17d6 100644 --- a/apps/hook/commands/plannotator-annotate.md +++ b/apps/hook/commands/plannotator-annotate.md @@ -10,6 +10,6 @@ disable-model-invocation: true ## Your task -If the output above is empty, the user closed the annotation session without providing feedback. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. -Otherwise, address the annotation feedback above. The user has reviewed the markdown file(s) and provided specific annotations and comments. +Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed the markdown file(s) and provided specific annotations and comments. diff --git a/apps/hook/commands/plannotator-last.md b/apps/hook/commands/plannotator-last.md index 6c132828..81f6aeed 100644 --- a/apps/hook/commands/plannotator-last.md +++ b/apps/hook/commands/plannotator-last.md @@ -10,6 +10,6 @@ disable-model-invocation: true ## Your task -If the output above is empty, the user closed the annotation session without providing feedback. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. -Otherwise, address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments. +Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed your last message and provided specific annotations and comments. From cf87eb802cccc06abb7dab0a58763efff47f37e0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 09:34:45 -0700 Subject: [PATCH 13/26] fix(opencode): forward raw args string to /plannotator-last handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command.execute.before dispatch was synthesizing a fake event with only sessionID, dropping the raw argument string before it reached handleAnnotateLastCommand's parseAnnotateArgs. Result: /plannotator-last --gate and --json silently no-op'd on OpenCode even though the docs and handler claimed support. Confirmed via OpenCode source that input.arguments on command.execute.before carries the raw tail string (the hook isn't in the typed plugin API but is populated at runtime — see packages/plugin/src/index.ts in opencode-ai/opencode). Also: document parseAnnotateArgs's known whitespace-tokenizer limitations (double-spaces in paths, literal --gate/--json in path names) in a comment. Fix not pursued — dev-context paths with those shapes are too rare to justify a full shell-style tokenizer. For provenance purposes, this commit was AI assisted. --- apps/opencode-plugin/index.ts | 4 +++- packages/shared/annotate-args.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 91e20df2..f8678e55 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -376,7 +376,9 @@ Do NOT proceed with implementation until your plan is approved.`); }; const feedback = await handleAnnotateLastCommand( - { properties: { sessionID: input.sessionID } }, + // input.arguments is the raw tail string from OpenCode's command dispatcher — + // needed so --gate / --json reach handleAnnotateLastCommand's parseAnnotateArgs (#570). + { properties: { sessionID: input.sessionID, arguments: input.arguments } }, deps ); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index b34351bc..a3e85b18 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -9,6 +9,14 @@ * arrives pre-joined from the harness slash-command dispatcher. The Claude * Code binary parses argv directly with indexOf/splice and does not use * this helper. + * + * Known limitation: this is a naive whitespace tokenizer. Paths that contain + * consecutive whitespace (double-space, tabs) get their spacing collapsed, + * and paths that literally contain `--gate`/`--json` as a whitespace-separated + * substring (e.g. `"Feature --gate spec.md"`) have that token stripped. A + * fuller shell-style tokenizer with quoting support would avoid both, but + * the tradeoff isn't worth it — dev-context paths with those shapes are + * vanishingly rare. */ export interface ParsedAnnotateArgs { From a8342389961a36bcccb751fc8ba9b9501dab9c79 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 11:13:36 -0700 Subject: [PATCH 14/26] fix(shared): preserve consecutive whitespace in annotate paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split/join tokenizer in parseAnnotateArgs was collapsing multi-whitespace runs in file paths (`My Notes.md` → `My Notes.md`, tabs → single space). Before this branch, OpenCode and Pi passed the raw args string straight to resolveUserPath; my own commit 53b87b22 introduced the regression by inserting a naive parser between them. Replace with a segment-preserving scanner: walk the string once, keep whitespace runs and non-whitespace tokens as separate segments. Remove only `--gate` / `--json` tokens plus one adjacent whitespace run. Final trim handles the edge where two adjacent flags both claim the same inter-flag whitespace. Tests added for the regression cases (double-space, tab, flag-at-start, no-flags multi-whitespace passthrough). Full test suite: 688/688 pass. For provenance purposes, this commit was AI assisted. --- packages/shared/annotate-args.test.ts | 38 +++++++++++++++ packages/shared/annotate-args.ts | 66 +++++++++++++++++++++------ 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts index 9399ff7d..a0231507 100644 --- a/packages/shared/annotate-args.test.ts +++ b/packages/shared/annotate-args.test.ts @@ -97,4 +97,42 @@ describe("parseAnnotateArgs", () => { json: true, }); }); + + // Regressions from the initial parser: the tokenize-and-rejoin approach + // collapsed consecutive whitespace in file paths. Before this branch, + // OpenCode and Pi passed the raw args string straight through, so files + // with double-spaces or tabs in their names worked fine. These tests pin + // that behavior so we don't regress it again. + + test("double-space inside a file path is preserved (flag at end)", () => { + expect(parseAnnotateArgs("My Notes.md --gate")).toEqual({ + filePath: "My Notes.md", + gate: true, + json: false, + }); + }); + + test("double-space inside a file path is preserved (flag at start)", () => { + expect(parseAnnotateArgs("--gate My Notes.md")).toEqual({ + filePath: "My Notes.md", + gate: true, + json: false, + }); + }); + + test("tab inside a file path is preserved", () => { + expect(parseAnnotateArgs("My\tNotes.md --gate")).toEqual({ + filePath: "My\tNotes.md", + gate: true, + json: false, + }); + }); + + test("multi-whitespace path with no flags passes through untouched", () => { + expect(parseAnnotateArgs("/tmp/My Notes.md")).toEqual({ + filePath: "/tmp/My Notes.md", + gate: false, + json: false, + }); + }); }); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index a3e85b18..eb87db17 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -10,13 +10,17 @@ * Code binary parses argv directly with indexOf/splice and does not use * this helper. * - * Known limitation: this is a naive whitespace tokenizer. Paths that contain - * consecutive whitespace (double-space, tabs) get their spacing collapsed, - * and paths that literally contain `--gate`/`--json` as a whitespace-separated - * substring (e.g. `"Feature --gate spec.md"`) have that token stripped. A - * fuller shell-style tokenizer with quoting support would avoid both, but - * the tradeoff isn't worth it — dev-context paths with those shapes are - * vanishingly rare. + * Implementation: walks the raw string once, preserving whitespace runs and + * non-whitespace tokens as separate segments. Only `--gate` / `--json` + * tokens (whole-word match) plus one adjacent whitespace run are removed. + * This keeps double-spaces and tabs inside file paths intact — which + * matches the pre-PR behavior on `main`, where OpenCode and Pi passed + * the raw args string straight through to the filesystem resolver. + * + * Remaining edge: if a path literally contains `--gate` or `--json` as a + * standalone whitespace-separated token (e.g. `"Feature --gate spec.md"`), + * that token is stripped. Supporting this would need shell-style quoting, + * which isn't worth the complexity for a vanishingly rare naming pattern. */ export interface ParsedAnnotateArgs { @@ -25,13 +29,49 @@ export interface ParsedAnnotateArgs { json: boolean; } +type Segment = { type: "ws" | "tok"; text: string }; + export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { - const tokens = (raw ?? "").trim().split(/\s+/).filter(Boolean); - const gate = tokens.includes("--gate"); - const json = tokens.includes("--json"); - const filePath = tokens - .filter((t) => t !== "--gate" && t !== "--json") - .join(" ") + const s = (raw ?? "").trim(); + let gate = false; + let json = false; + + const segments: Segment[] = []; + for (let i = 0; i < s.length;) { + const isWs = /\s/.test(s[i]); + const start = i; + while (i < s.length && /\s/.test(s[i]) === isWs) i++; + segments.push({ type: isWs ? "ws" : "tok", text: s.slice(start, i) }); + } + + const keep = segments.map(() => true); + for (let j = 0; j < segments.length; j++) { + const seg = segments[j]; + if (seg.type !== "tok") continue; + if (seg.text !== "--gate" && seg.text !== "--json") continue; + + if (seg.text === "--gate") gate = true; + else json = true; + keep[j] = false; + + // Drop one adjacent whitespace run so removed flags don't leave dangling + // spaces. Prefer trailing whitespace; fall back to leading if at the end. + if (j + 1 < segments.length && segments[j + 1].type === "ws") { + keep[j + 1] = false; + } else if (j > 0 && segments[j - 1].type === "ws") { + keep[j - 1] = false; + } + } + + // Trim covers the case where two adjacent flags (`... --gate --json`) + // both claim the single whitespace between them, leaving a trailing space + // after the kept token. + const filePath = segments + .filter((_, j) => keep[j]) + .map((seg) => seg.text) + .join("") + .trim() .replace(/^@/, ""); + return { filePath, gate, json }; } From eed859ec966ee39f7c04d7c52223da714ce9378b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 11:14:02 -0700 Subject: [PATCH 15/26] fix(pi): expose exit? on PlannotatorAnnotationResult type The runtime result object from openMarkdownAnnotation and openLastMessageAnnotation has always included exit? (pre-existing gap, not introduced by this PR), but the exported event-channel type was missing it. The guides/hook-integration.md doc I added explicitly advertises exit? as part of the annotation return shape, so the type contract and the public docs were disagreeing. Add exit?: boolean to the interface. Typecheck clean. For provenance purposes, this commit was AI assisted. --- apps/pi-extension/plannotator-events.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 7688276b..2b94d3d5 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -106,6 +106,8 @@ export interface PlannotatorAnnotatePayload { export interface PlannotatorAnnotationResult { feedback: string; + /** True when the reviewer closed the session without providing feedback. */ + exit?: boolean; /** True when the reviewer clicked Approve in review-gate mode, #570 */ approved?: boolean; } From 223c67175ca8c63e3e54de4e93ef77c28d312e62 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 11:14:40 -0700 Subject: [PATCH 16/26] fix(harnesses): port annotate template prose to Copilot + Gemini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code slash-command templates were updated in 0892088 to handle the new stdout shapes (empty on Approve/Close, JSON decision markers with --json). The Copilot and Gemini copies of those templates were missed — they still instructed the agent to "address the annotation feedback above" unconditionally, which hallucinates responses when the user passes --gate (empty output) or --json (JSON decision marker). Also: apps/copilot/commands/plannotator-last.md invoked `plannotator copilot-last` with no $ARGUMENTS forwarding, so --gate / --json couldn't reach the binary via that slash command on Copilot. Same bug pattern as apps/hook/commands/plannotator-last.md which was fixed in c55efb4 — this is the Copilot copy. Parity claim in the PR docs now actually holds across harnesses. For provenance purposes, this commit was AI assisted. --- apps/copilot/commands/plannotator-annotate.md | 4 +++- apps/copilot/commands/plannotator-last.md | 6 ++++-- apps/gemini/commands/plannotator-annotate.toml | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/copilot/commands/plannotator-annotate.md b/apps/copilot/commands/plannotator-annotate.md index 56dac088..0d32a69b 100644 --- a/apps/copilot/commands/plannotator-annotate.md +++ b/apps/copilot/commands/plannotator-annotate.md @@ -9,4 +9,6 @@ allowed-tools: shell(plannotator:*) ## Your task -Address the annotation feedback above. The user has reviewed the markdown file and provided specific annotations and comments. +If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. + +Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed the markdown file and provided specific annotations and comments. diff --git a/apps/copilot/commands/plannotator-last.md b/apps/copilot/commands/plannotator-last.md index b241a478..28682145 100644 --- a/apps/copilot/commands/plannotator-last.md +++ b/apps/copilot/commands/plannotator-last.md @@ -5,8 +5,10 @@ allowed-tools: shell(plannotator:*) ## Message Annotations -!`plannotator copilot-last` +!`plannotator copilot-last $ARGUMENTS` ## Your task -Address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments. +If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. + +Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed your last message and provided specific annotations and comments. diff --git a/apps/gemini/commands/plannotator-annotate.toml b/apps/gemini/commands/plannotator-annotate.toml index e6552dbe..ac7cf726 100644 --- a/apps/gemini/commands/plannotator-annotate.toml +++ b/apps/gemini/commands/plannotator-annotate.toml @@ -6,5 +6,7 @@ prompt = """ ## Your task -Address the annotation feedback above. The user has reviewed the markdown file and provided specific annotations and comments. +If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. + +Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed the markdown file and provided specific annotations and comments. """ From 01b87c3de26d5146ea9ab0e266152f0fc3f7a4fd Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 12:21:34 -0700 Subject: [PATCH 17/26] refactor(shared): consolidate @ reference handling in at-reference.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces packages/shared/at-reference.ts with two pure functions: stripAtPrefix(input): string Primary reference-mode resolution. Removes a leading `@`. resolveAtReference(input, exists): string | null Try stripped first (reference convention, first-class), fall back to literal if stripped doesn't resolve. Filesystem predicate is injected so the helper itself stays pure and trivially testable. Both functions replace scattered inline strip logic so every harness — Claude Code binary, OpenCode plugin, Pi extension — uses one source of truth. Currently wired: - parseAnnotateArgs (packages/shared/annotate-args.ts) calls stripAtPrefix - Claude Code binary (apps/hook/server/index.ts) calls stripAtPrefix - OpenCode + Pi inherit via parseAnnotateArgs resolveAtReference is exported for future use in HTML/folder branches that want literal-@ fallback (scoped-package-style names). Not wired yet. Pi vendor.sh updated so the helper ships into apps/pi-extension/generated on next build. Full test suite: 702/702 pass. For provenance purposes, this commit was AI assisted. --- apps/hook/server/index.ts | 7 ++- apps/pi-extension/vendor.sh | 2 +- packages/shared/annotate-args.test.ts | 23 +++++++- packages/shared/annotate-args.ts | 21 +++++--- packages/shared/at-reference.test.ts | 75 +++++++++++++++++++++++++++ packages/shared/at-reference.ts | 44 ++++++++++++++++ packages/shared/package.json | 3 +- 7 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 packages/shared/at-reference.test.ts create mode 100644 packages/shared/at-reference.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 7db2d261..328f010a 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -65,6 +65,7 @@ import { } from "@plannotator/server/annotate"; import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs"; import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; +import { stripAtPrefix } from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; @@ -513,10 +514,8 @@ if (args[0] === "sessions") { process.exit(1); } - // Strip @ prefix if present (Claude Code file reference syntax) - if (filePath.startsWith("@")) { - filePath = filePath.slice(1); - } + // Strip the `@` reference marker (shared helper — same rule every harness uses) + filePath = stripAtPrefix(filePath); // Use PLANNOTATOR_CWD if set (original working directory before script cd'd) const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index bc95cc21..48cb847c 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -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-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree html-to-markdown url-to-markdown tour annotate-args; do +for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree 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 diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts index a0231507..45f3234b 100644 --- a/packages/shared/annotate-args.test.ts +++ b/packages/shared/annotate-args.test.ts @@ -50,7 +50,12 @@ describe("parseAnnotateArgs", () => { }); }); - test("leading @ is stripped", () => { + // `@` is the reference-mode marker (Claude Code / OpenCode / Pi convention), + // not part of the filename. The parser strips it as the primary behavior — + // that's the common case. Scoped-package-style literal `@` paths are handled + // by a fallback deeper in the resolution stack (see at-reference.ts). + + test("leading @ is stripped (reference-mode primary)", () => { expect(parseAnnotateArgs("@spec.md --gate")).toEqual({ filePath: "spec.md", gate: true, @@ -58,6 +63,22 @@ describe("parseAnnotateArgs", () => { }); }); + test("scoped-package-style path has @ stripped (falls back to literal elsewhere)", () => { + expect(parseAnnotateArgs("@plannotator/ui/README.md")).toEqual({ + filePath: "plannotator/ui/README.md", + gate: false, + json: false, + }); + }); + + test("@ stripped when combined with --gate --json", () => { + expect(parseAnnotateArgs("@docs/spec.md --gate --json")).toEqual({ + filePath: "docs/spec.md", + gate: true, + json: true, + }); + }); + test("URL passes through", () => { expect(parseAnnotateArgs("https://example.com/docs --gate")).toEqual({ filePath: "https://example.com/docs", diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index eb87db17..679dfc24 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -2,8 +2,10 @@ * Parse CLI-style args arriving as a single whitespace-delimited string. * * Extracts the `--gate` and `--json` flags (issue #570) from the remainder, - * which is treated as the target path. Leading `@` is stripped to match the - * Claude Code path-arg convention used in apps/hook/server/index.ts. + * which is treated as the target path. Leading `@` is stripped via the + * shared at-reference helper — reference-mode is primary. Scoped-package- + * style literal `@` paths are handled by a fallback that the downstream + * resolver opts into (see at-reference.ts). * * Used by the OpenCode plugin and Pi extension, where the whole args string * arrives pre-joined from the harness slash-command dispatcher. The Claude @@ -23,6 +25,8 @@ * which isn't worth the complexity for a vanishingly rare naming pattern. */ +import { stripAtPrefix } from "./at-reference"; + export interface ParsedAnnotateArgs { filePath: string; gate: boolean; @@ -66,12 +70,13 @@ export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { // Trim covers the case where two adjacent flags (`... --gate --json`) // both claim the single whitespace between them, leaving a trailing space // after the kept token. - const filePath = segments - .filter((_, j) => keep[j]) - .map((seg) => seg.text) - .join("") - .trim() - .replace(/^@/, ""); + const filePath = stripAtPrefix( + segments + .filter((_, j) => keep[j]) + .map((seg) => seg.text) + .join("") + .trim(), + ); return { filePath, gate, json }; } diff --git a/packages/shared/at-reference.test.ts b/packages/shared/at-reference.test.ts new file mode 100644 index 00000000..2f8f46a5 --- /dev/null +++ b/packages/shared/at-reference.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from "bun:test"; +import { stripAtPrefix, resolveAtReference } from "./at-reference"; + +// The `@foo.md` convention — popularised by Claude Code but supported by +// several harnesses — treats `@` as a reference marker, not part of the +// filename. These helpers exist so every harness strips the same way and +// optionally falls back to the literal path for scoped-package-style names. + +describe("stripAtPrefix", () => { + test("removes a single leading @", () => { + expect(stripAtPrefix("@foo.md")).toBe("foo.md"); + }); + + test("removes only one @ (does not recurse)", () => { + expect(stripAtPrefix("@@foo.md")).toBe("@foo.md"); + }); + + test("leaves paths without @ unchanged", () => { + expect(stripAtPrefix("foo.md")).toBe("foo.md"); + }); + + test("leaves @ that is not at the start unchanged", () => { + expect(stripAtPrefix("dir/@foo.md")).toBe("dir/@foo.md"); + }); + + test("strips @ from scoped-package-style paths", () => { + expect(stripAtPrefix("@scope/pkg/README.md")).toBe("scope/pkg/README.md"); + }); + + test("handles empty string", () => { + expect(stripAtPrefix("")).toBe(""); + }); +}); + +describe("resolveAtReference", () => { + // Primary behavior: stripped form wins if it resolves. + test("returns the stripped path when it resolves", () => { + const exists = (p: string) => p === "foo.md"; + expect(resolveAtReference("@foo.md", exists)).toBe("foo.md"); + }); + + // Fallback: literal path used only when stripped form doesn't resolve. + test("falls back to the literal path when only that resolves", () => { + const exists = (p: string) => p === "@scope/pkg/README.md"; + expect(resolveAtReference("@scope/pkg/README.md", exists)).toBe("@scope/pkg/README.md"); + }); + + // When BOTH resolve, stripped form wins (reference-mode primacy). + test("prefers the stripped path when both resolve (reference wins)", () => { + const exists = (_p: string) => true; + expect(resolveAtReference("@foo.md", exists)).toBe("foo.md"); + }); + + // Returns null when neither candidate resolves — caller handles the error. + test("returns null when neither candidate resolves", () => { + const exists = (_p: string) => false; + expect(resolveAtReference("@nope.md", exists)).toBeNull(); + }); + + // Inputs without @ have no fallback, just a single existence check. + test("handles non-@ inputs with a single check", () => { + let calls = 0; + const exists = (p: string) => { calls++; return p === "plain.md"; }; + expect(resolveAtReference("plain.md", exists)).toBe("plain.md"); + expect(calls).toBe(1); + }); + + // Non-@ input that doesn't resolve returns null without a retry. + test("returns null for non-@ input that doesn't resolve", () => { + let calls = 0; + const exists = (_p: string) => { calls++; return false; }; + expect(resolveAtReference("missing.md", exists)).toBeNull(); + expect(calls).toBe(1); + }); +}); diff --git a/packages/shared/at-reference.ts b/packages/shared/at-reference.ts new file mode 100644 index 00000000..f2f8e357 --- /dev/null +++ b/packages/shared/at-reference.ts @@ -0,0 +1,44 @@ +/** + * `@`-reference handling for user-provided paths. + * + * Several agent harnesses (Claude Code, OpenCode, Pi) let users reference + * files with an `@` prefix, e.g. `@README.md`. The `@` is the team's + * reference marker, not part of the filename. Stripping it is the primary + * resolution path — that's the common case and it's supported first-class. + * + * The secondary path handles scoped-package-style names like + * `@scope/pkg/README.md`: if the stripped form doesn't resolve, fall back + * to the literal form so those paths still open. + * + * Both functions are pure and take any filesystem-ish predicate via a + * callback, so they're trivial to unit-test without stubbing anything. + */ + +/** + * Remove a single leading `@` from `input`. Leaves non-`@` strings and + * non-leading `@` characters alone. Does not recurse — `@@foo` becomes + * `@foo`, not `foo`. + */ +export function stripAtPrefix(input: string): string { + return input.startsWith("@") ? input.slice(1) : input; +} + +/** + * Resolve an `@`-prefixed user input by trying the stripped form first + * (reference mode, primary) and falling back to the literal form if the + * stripped form doesn't resolve. Returns the candidate that resolves, or + * null if neither does. + * + * `exists` defines what "resolves" means — use `existsSync` for a bare + * filesystem check, or wrap `resolveMarkdownFile` / `statSync` for richer + * predicates. The helper itself is filesystem-agnostic. + */ +export function resolveAtReference( + input: string, + exists: (candidate: string) => boolean, +): string | null { + const stripped = stripAtPrefix(input); + if (exists(stripped)) return stripped; + if (stripped !== input && exists(input)) return input; + return null; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 2dc78953..f86bbf6f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -27,7 +27,8 @@ "./html-to-markdown": "./html-to-markdown.ts", "./url-to-markdown": "./url-to-markdown.ts", "./tour": "./tour.ts", - "./annotate-args": "./annotate-args.ts" + "./annotate-args": "./annotate-args.ts", + "./at-reference": "./at-reference.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", From 42ef6ea3a9794b63d6369e24a033199ca82a2b0c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 12:29:15 -0700 Subject: [PATCH 18/26] feat(annotate): literal-@ fallback in every harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseAnnotateArgs now returns rawFilePath alongside the stripped filePath so dispatch code can fall back to the literal form when the stripped form doesn't resolve — the node_modules / scoped- package case (e.g. @plannotator/ui/README.md). Wired in every dispatch site: apps/hook/server/index.ts (binary: folder, HTML, markdown) apps/opencode-plugin/commands.ts (OpenCode plugin: HTML, markdown) apps/pi-extension/index.ts (Pi extension: all three) Folder and HTML branches use resolveAtReference(rawFilePath, existsSync- style predicate). Markdown branches use a small inline fallback against resolveMarkdownFile since that resolver has its own fuzzy-match and ambiguous-case handling we want to preserve. Reference-mode priority (strip wins when both exist) is preserved end- to-end — the helper tries stripped first on every call. #488's existing resolver tests pass unchanged. Full suite: 702/702. For provenance purposes, this commit was AI assisted. --- apps/hook/server/index.ts | 100 ++++++++++++++------------ apps/opencode-plugin/commands.ts | 10 ++- apps/pi-extension/index.ts | 18 ++++- packages/shared/annotate-args.test.ts | 31 ++++++-- packages/shared/annotate-args.ts | 24 ++++--- 5 files changed, 117 insertions(+), 66 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 328f010a..07593b79 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -65,7 +65,7 @@ import { } from "@plannotator/server/annotate"; import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs"; import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; -import { stripAtPrefix } from "@plannotator/shared/at-reference"; +import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; @@ -508,14 +508,16 @@ if (args[0] === "sessions") { // ANNOTATE MODE // ============================================ - let filePath = args[1]; - if (!filePath) { + const rawFilePath = args[1]; + if (!rawFilePath) { console.error("Usage: plannotator annotate [--no-jina]"); process.exit(1); } - // Strip the `@` reference marker (shared helper — same rule every harness uses) - filePath = stripAtPrefix(filePath); + // Primary resolution strips the `@` reference marker; rawFilePath is + // preserved so each branch can fall back to the literal form below + // (scoped-package-style names). + let filePath = stripAtPrefix(rawFilePath); // Use PLANNOTATOR_CWD if set (original working directory before script cd'd) const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); @@ -550,16 +552,14 @@ if (args[0] === "sessions") { absolutePath = filePath; // Use URL as the "path" for display sourceInfo = filePath; // Full URL for source attribution } else { - // Check if the argument is a directory (folder annotation mode) - const resolvedArg = resolveUserPath(filePath, projectRoot); - let isFolder = false; - try { - isFolder = statSync(resolvedArg).isDirectory(); - } catch { - // Not a directory, fall through to file resolution - } - - if (isFolder) { + // Folder check with literal-@ fallback for scoped-package-style names. + const folderCandidate = resolveAtReference(rawFilePath, (c) => { + try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } + catch { return false; } + }); + + if (folderCandidate !== null) { + const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { console.error(`No markdown or HTML files found in ${resolvedArg}`); @@ -570,41 +570,49 @@ if (args[0] === "sessions") { markdown = ""; annotateMode = "annotate-folder"; console.error(`Folder: ${resolvedArg}`); - } else if (/\.html?$/i.test(resolvedArg)) { - // HTML file annotation mode — convert to markdown via Turndown - if (!existsSync(resolvedArg)) { - console.error(`File not found: ${filePath}`); - process.exit(1); - } - const htmlFile = Bun.file(resolvedArg); - if (htmlFile.size > 10 * 1024 * 1024) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); - process.exit(1); - } - const html = await htmlFile.text(); - markdown = htmlToMarkdown(html); - absolutePath = resolvedArg; - sourceInfo = path.basename(resolvedArg); - console.error(`Converted: ${absolutePath}`); } else { - // Single markdown file annotation mode - const resolved = resolveMarkdownFile(filePath, projectRoot); + // HTML check with the same literal-@ fallback semantics. + const htmlCandidate = resolveAtReference(rawFilePath, (c) => { + const abs = resolveUserPath(c, projectRoot); + return /\.html?$/i.test(abs) && existsSync(abs); + }); + + if (htmlCandidate !== null) { + const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); + const htmlFile = Bun.file(resolvedArg); + if (htmlFile.size > 10 * 1024 * 1024) { + console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); + process.exit(1); + } + const html = await htmlFile.text(); + markdown = htmlToMarkdown(html); + absolutePath = resolvedArg; + sourceInfo = path.basename(resolvedArg); + console.error(`Converted: ${absolutePath}`); + } else { + // Single markdown file annotation mode + // Strip-first with literal-@ fallback (scoped-package-style names). + let resolved = resolveMarkdownFile(filePath, projectRoot); + if (resolved.kind === "not_found" && rawFilePath !== filePath) { + resolved = resolveMarkdownFile(rawFilePath, 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}`); + 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); } - process.exit(1); - } - if (resolved.kind === "not_found") { - console.error(`File not found: ${resolved.input}`); - process.exit(1); - } - absolutePath = resolved.path; - markdown = await Bun.file(absolutePath).text(); - console.error(`Resolved: ${absolutePath}`); + absolutePath = resolved.path; + markdown = await Bun.file(absolutePath).text(); + console.error(`Resolved: ${absolutePath}`); + } } } diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 19d427b6..26b2ae5a 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -157,7 +157,8 @@ export async function handleAnnotateCommand( // #570: split --gate / --json out of the args; rest is the file path. // --json is accepted silently (OpenCode writes to session, not stdout). // parseAnnotateArgs strips leading @ on filePath (reference-mode convention). - const { filePath, gate } = parseAnnotateArgs(rawArgs); + // `rawFilePath` preserves it for the scoped-package markdown fallback. + const { filePath, rawFilePath, gate } = parseAnnotateArgs(rawArgs); if (!filePath) { client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--gate] [--json]" }); @@ -207,7 +208,6 @@ export async function handleAnnotateCommand( annotateMode = "annotate-folder"; client.app.log({ level: "info", message: `Opening annotation UI for folder ${resolvedArg}...` }); } else if (/\.html?$/i.test(resolvedArg)) { - // HTML file annotation — convert to markdown via Turndown let fileSize: number; try { fileSize = statSync(resolvedArg).size; @@ -227,7 +227,11 @@ export async function handleAnnotateCommand( } else { // Markdown file annotation client.app.log({ level: "info", message: `Opening annotation UI for ${filePath}...` }); - const resolved = await resolveMarkdownFile(filePath, projectRoot); + // Strip-first with literal-@ fallback (scoped-package-style names). + let resolved = await resolveMarkdownFile(filePath, projectRoot); + if (resolved.kind === "not_found" && rawFilePath !== filePath) { + resolved = await resolveMarkdownFile(rawFilePath, projectRoot); + } if (resolved.kind === "ambiguous") { client.app.log({ diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 32c64c99..bb95fc06 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -38,6 +38,7 @@ import { htmlToMarkdown } from "./generated/html-to-markdown.js"; import { urlToMarkdown } from "./generated/url-to-markdown.js"; import { loadConfig, resolveUseJina } from "./generated/config.js"; import { parseAnnotateArgs } from "./generated/annotate-args.js"; +import { resolveAtReference } from "./generated/at-reference.js"; import { getLastAssistantMessageText, hasPlanBrowserHtml, @@ -340,7 +341,9 @@ export default function plannotator(pi: ExtensionAPI): void { handler: async (args, ctx) => { // #570: split --gate / --json from the path. --json is silently // accepted (Pi writes back via sendUserMessage, not stdout). - const { filePath, gate } = parseAnnotateArgs(args ?? ""); + // `rawFilePath` keeps any leading `@` for the literal-@ fallback + // (scoped-package-style names). + const { filePath, rawFilePath, gate } = parseAnnotateArgs(args ?? ""); if (!filePath) { ctx.ui.notify("Usage: /plannotator-annotate [--gate] [--json]", "error"); return; @@ -376,11 +379,20 @@ export default function plannotator(pi: ExtensionAPI): void { absolutePath = filePath; sourceInfo = filePath; } else { - absolutePath = resolveUserPath(filePath, ctx.cwd); - if (!existsSync(absolutePath)) { + // Pick the interpretation of the user input that actually exists: + // stripped form first (reference-mode primary), literal as fallback + // for scoped-package-style names. Falls back to the stripped form + // for the error message if neither exists. + const resolvedCandidate = resolveAtReference(rawFilePath, (c) => { + const abs = resolveUserPath(c, ctx.cwd); + return existsSync(abs); + }); + if (resolvedCandidate === null) { + absolutePath = resolveUserPath(filePath, ctx.cwd); ctx.ui.notify(`File not found: ${absolutePath}`, "error"); return; } + absolutePath = resolveUserPath(resolvedCandidate, ctx.cwd); try { isFolder = statSync(absolutePath).isDirectory(); diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts index 45f3234b..df5c4113 100644 --- a/packages/shared/annotate-args.test.ts +++ b/packages/shared/annotate-args.test.ts @@ -5,6 +5,7 @@ describe("parseAnnotateArgs", () => { test("path only", () => { expect(parseAnnotateArgs("spec.md")).toEqual({ filePath: "spec.md", + rawFilePath: "spec.md", gate: false, json: false, }); @@ -13,6 +14,7 @@ describe("parseAnnotateArgs", () => { test("path with --gate at end", () => { expect(parseAnnotateArgs("spec.md --gate")).toEqual({ filePath: "spec.md", + rawFilePath: "spec.md", gate: true, json: false, }); @@ -21,6 +23,7 @@ describe("parseAnnotateArgs", () => { test("--gate before path", () => { expect(parseAnnotateArgs("--gate spec.md")).toEqual({ filePath: "spec.md", + rawFilePath: "spec.md", gate: true, json: false, }); @@ -29,6 +32,7 @@ describe("parseAnnotateArgs", () => { test("path with both flags", () => { expect(parseAnnotateArgs("spec.md --gate --json")).toEqual({ filePath: "spec.md", + rawFilePath: "spec.md", gate: true, json: true, }); @@ -37,6 +41,7 @@ describe("parseAnnotateArgs", () => { test("flags only, no path", () => { expect(parseAnnotateArgs("--gate --json")).toEqual({ filePath: "", + rawFilePath: "", gate: true, json: true, }); @@ -45,35 +50,40 @@ describe("parseAnnotateArgs", () => { test("path with spaces rejoins with single space", () => { expect(parseAnnotateArgs("my file.md --gate")).toEqual({ filePath: "my file.md", + rawFilePath: "my file.md", gate: true, json: false, }); }); // `@` is the reference-mode marker (Claude Code / OpenCode / Pi convention), - // not part of the filename. The parser strips it as the primary behavior — - // that's the common case. Scoped-package-style literal `@` paths are handled - // by a fallback deeper in the resolution stack (see at-reference.ts). + // not part of the filename. The parser strips it on `filePath` as the primary + // behavior — that's the common case. `rawFilePath` preserves the original + // for callers that want to try the literal form as a fallback (scoped-package- + // style names). See at-reference.ts for the combined helper. - test("leading @ is stripped (reference-mode primary)", () => { + test("leading @ is stripped (reference-mode primary) and rawFilePath preserves it", () => { expect(parseAnnotateArgs("@spec.md --gate")).toEqual({ filePath: "spec.md", + rawFilePath: "@spec.md", gate: true, json: false, }); }); - test("scoped-package-style path has @ stripped (falls back to literal elsewhere)", () => { + test("scoped-package-style path: filePath stripped, rawFilePath literal", () => { expect(parseAnnotateArgs("@plannotator/ui/README.md")).toEqual({ filePath: "plannotator/ui/README.md", + rawFilePath: "@plannotator/ui/README.md", gate: false, json: false, }); }); - test("@ stripped when combined with --gate --json", () => { + test("@ stripped on filePath when combined with --gate --json, raw preserved", () => { expect(parseAnnotateArgs("@docs/spec.md --gate --json")).toEqual({ filePath: "docs/spec.md", + rawFilePath: "@docs/spec.md", gate: true, json: true, }); @@ -82,6 +92,7 @@ describe("parseAnnotateArgs", () => { test("URL passes through", () => { expect(parseAnnotateArgs("https://example.com/docs --gate")).toEqual({ filePath: "https://example.com/docs", + rawFilePath: "https://example.com/docs", gate: true, json: false, }); @@ -90,6 +101,7 @@ describe("parseAnnotateArgs", () => { test("extra whitespace is collapsed", () => { expect(parseAnnotateArgs(" spec.md --gate ")).toEqual({ filePath: "spec.md", + rawFilePath: "spec.md", gate: true, json: false, }); @@ -98,6 +110,7 @@ describe("parseAnnotateArgs", () => { test("empty string produces empty result", () => { expect(parseAnnotateArgs("")).toEqual({ filePath: "", + rawFilePath: "", gate: false, json: false, }); @@ -106,6 +119,7 @@ describe("parseAnnotateArgs", () => { test("nullish input is tolerated", () => { expect(parseAnnotateArgs(undefined as unknown as string)).toEqual({ filePath: "", + rawFilePath: "", gate: false, json: false, }); @@ -114,6 +128,7 @@ describe("parseAnnotateArgs", () => { test("folder path with trailing slash", () => { expect(parseAnnotateArgs("./specs/ --gate --json")).toEqual({ filePath: "./specs/", + rawFilePath: "./specs/", gate: true, json: true, }); @@ -128,6 +143,7 @@ describe("parseAnnotateArgs", () => { test("double-space inside a file path is preserved (flag at end)", () => { expect(parseAnnotateArgs("My Notes.md --gate")).toEqual({ filePath: "My Notes.md", + rawFilePath: "My Notes.md", gate: true, json: false, }); @@ -136,6 +152,7 @@ describe("parseAnnotateArgs", () => { test("double-space inside a file path is preserved (flag at start)", () => { expect(parseAnnotateArgs("--gate My Notes.md")).toEqual({ filePath: "My Notes.md", + rawFilePath: "My Notes.md", gate: true, json: false, }); @@ -144,6 +161,7 @@ describe("parseAnnotateArgs", () => { test("tab inside a file path is preserved", () => { expect(parseAnnotateArgs("My\tNotes.md --gate")).toEqual({ filePath: "My\tNotes.md", + rawFilePath: "My\tNotes.md", gate: true, json: false, }); @@ -152,6 +170,7 @@ describe("parseAnnotateArgs", () => { test("multi-whitespace path with no flags passes through untouched", () => { expect(parseAnnotateArgs("/tmp/My Notes.md")).toEqual({ filePath: "/tmp/My Notes.md", + rawFilePath: "/tmp/My Notes.md", gate: false, json: false, }); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index 679dfc24..6f4d93ca 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -28,7 +28,17 @@ import { stripAtPrefix } from "./at-reference"; export interface ParsedAnnotateArgs { + /** + * Primary resolution path with any leading `@` stripped (reference-mode + * convention). Most call sites should use this directly. + */ filePath: string; + /** + * Raw path with the `@` prefix preserved (if the user supplied one). + * Callers that want the literal-`@` fallback for scoped-package-style + * paths pair this with `resolveAtReference` from at-reference.ts. + */ + rawFilePath: string; gate: boolean; json: boolean; } @@ -70,13 +80,11 @@ export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { // Trim covers the case where two adjacent flags (`... --gate --json`) // both claim the single whitespace between them, leaving a trailing space // after the kept token. - const filePath = stripAtPrefix( - segments - .filter((_, j) => keep[j]) - .map((seg) => seg.text) - .join("") - .trim(), - ); + const rawFilePath = segments + .filter((_, j) => keep[j]) + .map((seg) => seg.text) + .join("") + .trim(); - return { filePath, gate, json }; + return { filePath: stripAtPrefix(rawFilePath), rawFilePath, gate, json }; } From 127ce812349d036a1ec60e9983230fa6c0b238a8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 12:39:28 -0700 Subject: [PATCH 19/26] docs(guides): add Annotate Gates and JSON Responses Dedicated capability-reference page for the --gate and --json flags. Shows the stdout matrix at the top, explains what each flag does, covers the three primary use cases (spec-driven frameworks, Stop-hook turn review, programmatic decision routing), and cross-links to hook-integration.md for copy-paste recipes. Declarative tone, no em dashes. For provenance purposes, this commit was AI assisted. --- .../annotate-gates-and-json-responses.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md diff --git a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md new file mode 100644 index 00000000..528f30ae --- /dev/null +++ b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md @@ -0,0 +1,79 @@ +--- +title: "Annotate Gates and JSON Responses" +description: "The --gate and --json flags extend plannotator annotate from a feedback tool into a structured review gate with machine-readable decisions. Use them to wire Plannotator into spec-driven workflows, Stop hooks, and agent pipelines." +sidebar: + order: 28 +section: "Guides" +--- + +`plannotator annotate` and `plannotator annotate-last` accept two flags that turn markdown annotation into a full review gate with structured output. + +## Capabilities + +- **`--gate`** adds an Approve button to the annotation UI. The reviewer picks one of three decisions: approve, send annotations, or close. +- **`--json`** emits every decision as a structured JSON object on stdout so hooks and plugins can route on the outcome without parsing free text. +- The flags compose. Use them together, separately, or not at all. +- Identical semantics across every supported harness: Claude Code, Copilot CLI, Gemini CLI, OpenCode, Pi, and Codex. + +## Stdout contract + +``` + Flags │ UX │ Approve │ Close │ Annotate +─────────────── ┼──────────────────┼─────────────────────────┼──────────────────────────┼─────────────────────────────────────────────── + (none) │ 2-button │ n/a │ empty │ feedback (plaintext) + --gate │ 3-button │ empty │ empty │ feedback (plaintext) + --json │ 2-button │ n/a │ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} + --gate --json │ 3-button │ {"decision":"approved"}│ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} +``` + +The JSON schema is minimal by design: + +```json +{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." } +``` + +`feedback` appears only when `decision` is `annotated`. Everything else is a single-field object. + +## `--gate` + +A three-way review decision. The annotation UI adds an Approve button alongside Close and Send Annotations. The reviewer declares intent explicitly: + +- **Approve.** The artifact is good as written. The agent should proceed. +- **Send Annotations.** The reviewer has specific changes. The feedback is returned verbatim. +- **Close.** The session ends without a decision. Neither a signal to the agent nor an instruction set. + +In plaintext mode, Approve and Close are indistinguishable on stdout. Both emit nothing. That is intentional: it matches Claude Code's native PostToolUse convention where empty stdout means "allow" and non-empty stdout means "block with this as the reason." Naive hooks work out of the box. No parsing, no JSON, no logic. + +## `--json` + +Structured stdout. Every decision is emitted as a JSON object with a `decision` field and optionally a `feedback` payload. Hooks and plugins that need explicit routing (log approvals separately from dismissals, gate on decision type, accumulate telemetry) use this. + +`--json` is orthogonal to `--gate`: + +- `--json` alone keeps the two-button UI. Only `annotated` and `dismissed` decisions are emitted. +- `--gate --json` unlocks all three decisions in structured form. +- On OpenCode and Pi, `--json` is accepted silently. Those harnesses write back to the session directly rather than via stdout, so the flag has no effect there. Recipes remain portable. + +## Primary use cases + +### Spec-driven development frameworks + +Spec-driven development frameworks like spec-kit, kiro, and openspec generate multiple markdown artifacts per feature: `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`. Each goes through clarify, review, and approve cycles. Plannotator's annotation UI is a first-class fit for reviewing these artifacts: inline, targeted feedback on markdown is exactly what these workflows need. + +With `--gate`, a PostToolUse hook on Write triggers a full review gate every time the agent produces a spec artifact. The reviewer approves, annotates, or dismisses. The agent proceeds, revises, or skips accordingly. + +### Turn-by-turn review + +`plannotator annotate-last --gate` wired into a Claude Code Stop hook pauses every agent turn for human review. Approve closes the turn cleanly. Send Annotations re-prompts the agent with the reviewer's feedback. Close ends the turn without injecting anything. + +### Programmatic decision routing + +When a hook or plugin needs to distinguish approve from dismiss, `--json` provides a single-line, stable contract. One-shot decisions become machine-readable events. No stdout parsing, no fragility. + +## Hook integration recipes + +See [Hook Integration](/docs/guides/hook-integration/) for copy-paste recipes that wire these flags into PostToolUse and Stop hooks on Claude Code, plus portable variants for OpenCode and Pi. + +## Exit codes + +Every decision exits `0`. Signals live on stdout. This keeps Plannotator composable with harnesses that use exit codes for their own purposes. From 9db82592a4e5dd5e2d8e5d39bb94b11e37ecffd8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 12:43:46 -0700 Subject: [PATCH 20/26] docs(guides): add JSON schema and example outputs Shows the actual shape of each decision object (approved, dismissed, annotated) plus the annotated example with a real feedback payload. Clarifies that each invocation emits a single line of JSON. For provenance purposes, this commit was AI assisted. --- .../annotate-gates-and-json-responses.md | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md index 528f30ae..89e6afb7 100644 --- a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md +++ b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md @@ -26,13 +26,39 @@ section: "Guides" --gate --json │ 3-button │ {"decision":"approved"}│ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} ``` -The JSON schema is minimal by design: +### JSON schema ```json -{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." } +{ + "decision": "approved" | "annotated" | "dismissed", + "feedback": "string (present only when decision is 'annotated')" +} ``` -`feedback` appears only when `decision` is `annotated`. Everything else is a single-field object. +### Example outputs + +**Approved** (reviewer clicked Approve, `--gate --json`): + +```json +{"decision":"approved"} +``` + +**Dismissed** (reviewer clicked Close, `--json` or `--gate --json`): + +```json +{"decision":"dismissed"} +``` + +**Annotated** (reviewer sent annotations, `--json` or `--gate --json`). The `feedback` field is the same markdown Plannotator emits in plaintext mode: + +```json +{ + "decision": "annotated", + "feedback": "# File Feedback\n\nI've reviewed this file and have 2 pieces of feedback:\n\n## 1. Remove this\n`the selected text`\n> I don't want this.\n\n## 2. Feedback on: \"some highlighted text\"\n> This needs more detail.\n\n---" +} +``` + +The object is emitted as a single line of JSON per invocation. One invocation, one decision, one line on stdout. ## `--gate` From ab54dba5d82b58e9a2fbc3e18399a14be8277916 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 13:02:57 -0700 Subject: [PATCH 21/26] fix(shared): strip wrapping quotes in @-reference handling OpenCode and Pi don't go through a shell, so quoted paths arrive at the parser with literal quote characters intact. stripAtPrefix was checking startsWith("@") against the quote character, never seeing the @, and the reference-mode strip silently skipped. Export stripWrappingQuotes from resolve-file.ts (already existed as a private helper). Use it in stripAtPrefix before the @ check, and in parseAnnotateArgs when building rawFilePath so every downstream caller gets a clean string without tokenization artifacts. Covers `"@foo.md"`, `'@foo.md'`, `"@My Notes.md"`, and `"foo.md"` (quotes without @). Mismatched quotes are left alone. Terminal CLI usage is unchanged because the shell already unwraps quotes before the binary receives argv. Tests: 711/711 pass. For provenance purposes, this commit was AI assisted. --- packages/shared/annotate-args.test.ts | 41 +++++++++++++++++++++++++++ packages/shared/annotate-args.ts | 17 +++++++---- packages/shared/at-reference.test.ts | 24 ++++++++++++++++ packages/shared/at-reference.ts | 16 ++++++++--- packages/shared/resolve-file.ts | 2 +- 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts index df5c4113..884a0133 100644 --- a/packages/shared/annotate-args.test.ts +++ b/packages/shared/annotate-args.test.ts @@ -175,4 +175,45 @@ describe("parseAnnotateArgs", () => { json: false, }); }); + + // OpenCode and Pi don't go through a shell, so users who quote paths + // (shell muscle memory, copy-paste from docs) have literal quote + // characters reach the parser. Strip them at the tokenization layer + // so downstream callers don't have to reason about quoting. + + test("wrapping double quotes are stripped from both filePath and rawFilePath", () => { + expect(parseAnnotateArgs(`"@foo.md" --gate`)).toEqual({ + filePath: "foo.md", + rawFilePath: "@foo.md", + gate: true, + json: false, + }); + }); + + test("wrapping single quotes are stripped", () => { + expect(parseAnnotateArgs(`'@foo.md' --gate`)).toEqual({ + filePath: "foo.md", + rawFilePath: "@foo.md", + gate: true, + json: false, + }); + }); + + test("wrapping quotes around a path with spaces", () => { + expect(parseAnnotateArgs(`"@My Notes.md" --gate`)).toEqual({ + filePath: "My Notes.md", + rawFilePath: "@My Notes.md", + gate: true, + json: false, + }); + }); + + test("wrapping quotes without @ still get stripped", () => { + expect(parseAnnotateArgs(`"My Notes.md"`)).toEqual({ + filePath: "My Notes.md", + rawFilePath: "My Notes.md", + gate: false, + json: false, + }); + }); }); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index 6f4d93ca..5f564992 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -26,6 +26,7 @@ */ import { stripAtPrefix } from "./at-reference"; +import { stripWrappingQuotes } from "./resolve-file"; export interface ParsedAnnotateArgs { /** @@ -79,12 +80,16 @@ export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { // Trim covers the case where two adjacent flags (`... --gate --json`) // both claim the single whitespace between them, leaving a trailing space - // after the kept token. - const rawFilePath = segments - .filter((_, j) => keep[j]) - .map((seg) => seg.text) - .join("") - .trim(); + // after the kept token. Wrapping quotes come from OpenCode/Pi users who + // quote paths with spaces (shell muscle memory); strip them here so + // downstream callers never see tokenization artifacts. + const rawFilePath = stripWrappingQuotes( + segments + .filter((_, j) => keep[j]) + .map((seg) => seg.text) + .join("") + .trim(), + ); return { filePath: stripAtPrefix(rawFilePath), rawFilePath, gate, json }; } diff --git a/packages/shared/at-reference.test.ts b/packages/shared/at-reference.test.ts index 2f8f46a5..fbcf7f93 100644 --- a/packages/shared/at-reference.test.ts +++ b/packages/shared/at-reference.test.ts @@ -30,6 +30,30 @@ describe("stripAtPrefix", () => { test("handles empty string", () => { expect(stripAtPrefix("")).toBe(""); }); + + // Wrapping quotes come from harnesses that tokenize on whitespace (OpenCode, + // Pi). Users have to quote paths with spaces: `"@My Notes.md"`. Without + // unwrapping the quotes first, stripAtPrefix would never see the `@`. + test("strips wrapping double quotes before stripping @", () => { + expect(stripAtPrefix(`"@foo.md"`)).toBe("foo.md"); + }); + + test("strips wrapping single quotes before stripping @", () => { + expect(stripAtPrefix(`'@foo.md'`)).toBe("foo.md"); + }); + + test("strips wrapping quotes around a path with spaces", () => { + expect(stripAtPrefix(`"@My Notes.md"`)).toBe("My Notes.md"); + }); + + test("strips wrapping quotes when no @ present", () => { + expect(stripAtPrefix(`"foo.md"`)).toBe("foo.md"); + }); + + test("leaves mismatched quotes alone (not wrapping)", () => { + expect(stripAtPrefix(`"@foo.md`)).toBe(`"@foo.md`); + expect(stripAtPrefix(`@foo.md"`)).toBe(`foo.md"`); + }); }); describe("resolveAtReference", () => { diff --git a/packages/shared/at-reference.ts b/packages/shared/at-reference.ts index f2f8e357..892d6df2 100644 --- a/packages/shared/at-reference.ts +++ b/packages/shared/at-reference.ts @@ -14,13 +14,21 @@ * callback, so they're trivial to unit-test without stubbing anything. */ +import { stripWrappingQuotes } from "./resolve-file"; + /** - * Remove a single leading `@` from `input`. Leaves non-`@` strings and - * non-leading `@` characters alone. Does not recurse — `@@foo` becomes - * `@foo`, not `foo`. + * Normalize a user-typed path reference by unwrapping matching `"..."` or + * `'...'` quotes and removing a single leading `@`. Quotes come from + * harnesses that tokenize on whitespace (OpenCode, Pi), where paths + * containing spaces have to be quoted. The quote-stripping has to run + * first so the `@` check sees the real first character. + * + * Non-`@` inputs are returned unchanged except for quote unwrapping. + * Does not recurse: `@@foo` becomes `@foo`, not `foo`. */ export function stripAtPrefix(input: string): string { - return input.startsWith("@") ? input.slice(1) : input; + const unquoted = stripWrappingQuotes(input); + return unquoted.startsWith("@") ? unquoted.slice(1) : unquoted; } /** diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts index 2ef9644b..c56c3930 100644 --- a/packages/shared/resolve-file.ts +++ b/packages/shared/resolve-file.ts @@ -55,7 +55,7 @@ export function expandHomePath(input: string, home = homedir()): string { return input; } -function stripWrappingQuotes(input: string): string { +export function stripWrappingQuotes(input: string): string { if (input.length < 2) { return input; } From 9f13f253018fd89ab57a3092e00f6ffb83a3ea8b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 13:33:23 -0700 Subject: [PATCH 22/26] docs+templates: distinguish approved from dismissed in agent prompts Two changes: 1. CLAUDE.md Annotate Server API table now lists /api/approve and /api/exit, plus the gate field on the /api/plan response. The internal developer reference matches the public marketing docs and the actual server code. 2. All five annotate slash-command templates (Claude Code annotate and last, Copilot annotate and last, Gemini annotate) split the "approved or dismissed" branch into two. With --json on, the agent now acknowledges approvals with "Approved." and dismissals with "Annotation session closed." The explicit approval signal from the reviewer reaches the agent instead of being collapsed into a neutral close. Plaintext mode (no --json) is unchanged since approve and close both emit empty stdout by design. For provenance purposes, this commit was AI assisted. --- apps/copilot/commands/plannotator-annotate.md | 6 ++++-- apps/copilot/commands/plannotator-last.md | 6 ++++-- apps/gemini/commands/plannotator-annotate.toml | 6 ++++-- apps/hook/commands/plannotator-annotate.md | 6 ++++-- apps/hook/commands/plannotator-last.md | 6 ++++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/copilot/commands/plannotator-annotate.md b/apps/copilot/commands/plannotator-annotate.md index 0d32a69b..c64c046e 100644 --- a/apps/copilot/commands/plannotator-annotate.md +++ b/apps/copilot/commands/plannotator-annotate.md @@ -9,6 +9,8 @@ allowed-tools: shell(plannotator:*) ## Your task -If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +The output above will be one of: -Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed the markdown file and provided specific annotations and comments. +1. A JSON object with `"decision": "approved"`. The user approved the markdown file. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file and provided specific annotations and comments. diff --git a/apps/copilot/commands/plannotator-last.md b/apps/copilot/commands/plannotator-last.md index 28682145..0ae22117 100644 --- a/apps/copilot/commands/plannotator-last.md +++ b/apps/copilot/commands/plannotator-last.md @@ -9,6 +9,8 @@ allowed-tools: shell(plannotator:*) ## Your task -If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +The output above will be one of: -Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed your last message and provided specific annotations and comments. +1. A JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed your last message and provided specific annotations and comments. diff --git a/apps/gemini/commands/plannotator-annotate.toml b/apps/gemini/commands/plannotator-annotate.toml index ac7cf726..b47cc81d 100644 --- a/apps/gemini/commands/plannotator-annotate.toml +++ b/apps/gemini/commands/plannotator-annotate.toml @@ -6,7 +6,9 @@ prompt = """ ## Your task -If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +The output above will be one of: -Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed the markdown file and provided specific annotations and comments. +1. A JSON object with `"decision": "approved"`. The user approved the markdown file. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file and provided specific annotations and comments. """ diff --git a/apps/hook/commands/plannotator-annotate.md b/apps/hook/commands/plannotator-annotate.md index 4c3b17d6..02ee3208 100644 --- a/apps/hook/commands/plannotator-annotate.md +++ b/apps/hook/commands/plannotator-annotate.md @@ -10,6 +10,8 @@ disable-model-invocation: true ## Your task -If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +The output above will be one of: -Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed the markdown file(s) and provided specific annotations and comments. +1. A JSON object with `"decision": "approved"`. The user approved the markdown file(s). Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file(s) and provided specific annotations and comments. diff --git a/apps/hook/commands/plannotator-last.md b/apps/hook/commands/plannotator-last.md index 81f6aeed..d683e1ef 100644 --- a/apps/hook/commands/plannotator-last.md +++ b/apps/hook/commands/plannotator-last.md @@ -10,6 +10,8 @@ disable-model-invocation: true ## Your task -If the output above is empty, OR is a JSON object whose `"decision"` is `"approved"` or `"dismissed"`, the user closed the annotation session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +The output above will be one of: -Otherwise the output is either plaintext annotation feedback or a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback — the user has reviewed your last message and provided specific annotations and comments. +1. A JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. +3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed your last message and provided specific annotations and comments. From a155c6ccde93747baca10eeb92bc3b561fd2c8c6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 13:33:51 -0700 Subject: [PATCH 23/26] docs(agents): update Annotate Server API table with /api/approve + gate Follow-up to 345b42a which intended to include this file but staged the symlink (CLAUDE.md) instead of the target (AGENTS.md). The table now lists /api/approve, /api/exit, and the gate field on /api/plan so the internal developer reference matches the public marketing docs and the actual server code. For provenance purposes, this commit was AI assisted. --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 971609f5..5dac5e3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,8 +256,10 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | -| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo? }` | +| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate }` | | `/api/feedback` | POST | Submit annotations (body: feedback, annotations) | +| `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) | +| `/api/exit` | POST | Close session without feedback | | `/api/image` | GET | Serve image by path query param | | `/api/upload` | POST | Upload image, returns `{ path, originalName }` | | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | From acb8053e2175d92f95df2b81735cc1207f685e0f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 14:20:41 -0700 Subject: [PATCH 24/26] feat(annotate): --gate plaintext approve emits "The user approved." MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously --gate Approve emitted empty stdout, identical to Close, to preserve a specific naive-hook pattern where any stdout blocks. That made plaintext --gate approvals indistinguishable from closes for every other consumer — slash command templates especially, which ended up collapsing approval into a "session closed" acknowledgement. Now Approve emits the exact line "The user approved." on stdout in plaintext --gate mode. Close stays empty. Send Annotations stays feedback markdown. All three outputs are unambiguous without JSON. Slash command templates (Claude Code, Copilot, Gemini) updated to recognize either the plaintext marker or the JSON form. Stdout matrix updated in every doc that references it. Hook integration recipes note the behavior change for users whose hook scripts treat any stdout as a block signal — the fix is trivial (filter the marker, or use --json). JSON mode unchanged. OpenCode and Pi unaffected (they route on the decision object directly, not stdout). For provenance purposes, this commit was AI assisted. --- apps/copilot/commands/plannotator-annotate.md | 2 +- apps/copilot/commands/plannotator-last.md | 2 +- apps/gemini/commands/plannotator-annotate.toml | 2 +- apps/hook/commands/plannotator-annotate.md | 2 +- apps/hook/commands/plannotator-last.md | 2 +- apps/hook/server/index.ts | 15 ++++++++++++--- .../src/content/docs/commands/annotate.md | 4 ++-- .../guides/annotate-gates-and-json-responses.md | 4 ++-- .../src/content/docs/guides/hook-integration.md | 12 ++++++------ 9 files changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/copilot/commands/plannotator-annotate.md b/apps/copilot/commands/plannotator-annotate.md index c64c046e..b0c1ca79 100644 --- a/apps/copilot/commands/plannotator-annotate.md +++ b/apps/copilot/commands/plannotator-annotate.md @@ -11,6 +11,6 @@ allowed-tools: shell(plannotator:*) The output above will be one of: -1. A JSON object with `"decision": "approved"`. The user approved the markdown file. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved the markdown file. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. 2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. 3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file and provided specific annotations and comments. diff --git a/apps/copilot/commands/plannotator-last.md b/apps/copilot/commands/plannotator-last.md index 0ae22117..06b507d3 100644 --- a/apps/copilot/commands/plannotator-last.md +++ b/apps/copilot/commands/plannotator-last.md @@ -11,6 +11,6 @@ allowed-tools: shell(plannotator:*) The output above will be one of: -1. A JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. 2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. 3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed your last message and provided specific annotations and comments. diff --git a/apps/gemini/commands/plannotator-annotate.toml b/apps/gemini/commands/plannotator-annotate.toml index b47cc81d..1ed2a9af 100644 --- a/apps/gemini/commands/plannotator-annotate.toml +++ b/apps/gemini/commands/plannotator-annotate.toml @@ -8,7 +8,7 @@ prompt = """ The output above will be one of: -1. A JSON object with `"decision": "approved"`. The user approved the markdown file. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved the markdown file. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. 2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. 3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file and provided specific annotations and comments. """ diff --git a/apps/hook/commands/plannotator-annotate.md b/apps/hook/commands/plannotator-annotate.md index 02ee3208..4da50d9e 100644 --- a/apps/hook/commands/plannotator-annotate.md +++ b/apps/hook/commands/plannotator-annotate.md @@ -12,6 +12,6 @@ disable-model-invocation: true The output above will be one of: -1. A JSON object with `"decision": "approved"`. The user approved the markdown file(s). Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved the markdown file(s). Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. 2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. 3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file(s) and provided specific annotations and comments. diff --git a/apps/hook/commands/plannotator-last.md b/apps/hook/commands/plannotator-last.md index d683e1ef..51f8b5f4 100644 --- a/apps/hook/commands/plannotator-last.md +++ b/apps/hook/commands/plannotator-last.md @@ -12,6 +12,6 @@ disable-model-invocation: true The output above will be one of: -1. A JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. +1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work. 2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work. 3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed your last message and provided specific annotations and comments. diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 07593b79..67287030 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -135,9 +135,14 @@ const jsonFlag = jsonIdx !== -1; if (jsonFlag) args.splice(jsonIdx, 1); // Stdout matrix for annotate / annotate-last / copilot annotate-last (#570). -// Approve and Close both emit empty stdout in plaintext mode so naive PostToolUse -// and Stop hooks (empty = allow, non-empty = block) work without parsing. +// Plaintext mode: +// - Close emits empty stdout (naive PostToolUse / Stop hooks: empty = allow). +// - Approve emits "The user approved." so agents and templates can +// distinguish approval from close without needing --json. +// - Send Annotations emits the plaintext feedback markdown. // --json switches to structured output across all three decisions. +export const APPROVED_PLAINTEXT_MARKER = "The user approved."; + function emitAnnotateOutcome(result: { feedback: string; exit?: boolean; @@ -153,7 +158,11 @@ function emitAnnotateOutcome(result: { } return; } - if (result.approved || result.exit) return; // empty stdout + if (result.exit) return; // empty stdout on close + if (result.approved) { + console.log(APPROVED_PLAINTEXT_MARKER); + return; + } if (result.feedback) console.log(result.feedback); } diff --git a/apps/marketing/src/content/docs/commands/annotate.md b/apps/marketing/src/content/docs/commands/annotate.md index c80b1c2a..222b66d6 100644 --- a/apps/marketing/src/content/docs/commands/annotate.md +++ b/apps/marketing/src/content/docs/commands/annotate.md @@ -128,11 +128,11 @@ Switches stdout to a structured decision object so hooks can route programmatica | Flags | UX | Approve | Close | Send Annotations | |---|---|---|---|---| | *(none)* | 2-button | n/a | empty | feedback (plaintext) | -| `--gate` | 3-button | empty | empty | feedback (plaintext) | +| `--gate` | 3-button | `The user approved.` | empty | feedback (plaintext) | | `--json` | 2-button | n/a | `{"decision":"dismissed"}` | `{"decision":"annotated","feedback":"..."}` | | `--gate --json` | 3-button | `{"decision":"approved"}` | `{"decision":"dismissed"}` | `{"decision":"annotated","feedback":"..."}` | -**Key property:** in `--gate` mode without `--json`, Approve and Close both emit empty stdout, so naive PostToolUse / Stop hooks (empty = allow, non-empty = block) work with no parsing. Only Send Annotations blocks. Add `--json` when you need explicit approved-vs-dismissed telemetry. +**Key property:** `--gate` plaintext output is unambiguous across all three decisions. Close is empty, Send Annotations is feedback markdown, Approve is the exact line `The user approved.` — each case distinguishable without JSON parsing. Use `--json` when you want machine-readable decision objects instead of string matching. On OpenCode and Pi, `--json` is silently accepted because those harnesses write back into the session directly rather than via stdout. The `--gate` flag behaves identically across all three harnesses. diff --git a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md index 89e6afb7..77632d87 100644 --- a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md +++ b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md @@ -21,7 +21,7 @@ section: "Guides" Flags │ UX │ Approve │ Close │ Annotate ─────────────── ┼──────────────────┼─────────────────────────┼──────────────────────────┼─────────────────────────────────────────────── (none) │ 2-button │ n/a │ empty │ feedback (plaintext) - --gate │ 3-button │ empty │ empty │ feedback (plaintext) + --gate │ 3-button │ `The user approved.` │ empty │ feedback (plaintext) --json │ 2-button │ n/a │ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} --gate --json │ 3-button │ {"decision":"approved"}│ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} ``` @@ -68,7 +68,7 @@ A three-way review decision. The annotation UI adds an Approve button alongside - **Send Annotations.** The reviewer has specific changes. The feedback is returned verbatim. - **Close.** The session ends without a decision. Neither a signal to the agent nor an instruction set. -In plaintext mode, Approve and Close are indistinguishable on stdout. Both emit nothing. That is intentional: it matches Claude Code's native PostToolUse convention where empty stdout means "allow" and non-empty stdout means "block with this as the reason." Naive hooks work out of the box. No parsing, no JSON, no logic. +In plaintext mode, Approve emits the single line `The user approved.` on stdout so templates and agents can distinguish approval from close without needing `--json`. Close emits nothing. Send Annotations emits the feedback markdown. Hook authors who treat any non-empty stdout as a block signal need to filter the approve marker (or use `--json` for cleaner routing). ## `--json` diff --git a/apps/marketing/src/content/docs/guides/hook-integration.md b/apps/marketing/src/content/docs/guides/hook-integration.md index d1a5b4a7..b79c88f1 100644 --- a/apps/marketing/src/content/docs/guides/hook-integration.md +++ b/apps/marketing/src/content/docs/guides/hook-integration.md @@ -11,7 +11,7 @@ The `--gate` and `--json` flags on `plannotator annotate` and `plannotator annot See [Annotate → Flags](/docs/commands/annotate/#flags) for the full stdout matrix. The short version: - `--gate` adds a three-button UX (Approve / Send Annotations / Close). -- Without `--json`: Approve and Close emit empty stdout; Send Annotations emits the feedback markdown. Matches Claude Code's natural PostToolUse / Stop hook convention (empty = allow, non-empty = block). +- Without `--json`: Approve emits the line `The user approved.`, Close emits empty stdout, Send Annotations emits the feedback markdown. Three distinguishable outputs without parsing JSON. - With `--json`: every decision emits a structured `{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." }` object. ## Recipe 1: PostToolUse spec gate @@ -43,11 +43,11 @@ Add to `.claude/hooks.json`: Behavior: -- **Approve** → empty stdout → Claude Code proceeds as normal. -- **Send Annotations** → feedback on stdout → Claude Code blocks the turn with the feedback as the reason. -- **Close** → empty stdout → Claude Code proceeds. +- **Approve** → `The user approved.` on stdout. Claude Code reports the line back and proceeds. +- **Send Annotations** → feedback markdown on stdout. Claude Code reports the feedback back. +- **Close** → empty stdout. Claude Code proceeds silently. -No parsing needed. Approve and Close are equivalent from the agent's perspective; the human distinction is preserved in the UI. +If your hook treats any non-empty stdout as a block signal (some scripts do), filter the approve marker explicitly, or use the `--json` recipe below to route on the parsed decision instead. ### Structured (`--json`) @@ -100,7 +100,7 @@ Wire `annotate-last` to Claude Code's Stop hook to pause every agent turn for hu Behavior: -- **Approve** → empty stdout → the turn ends normally, control returns to the user. +- **Approve** → `The user approved.` on stdout. Turn ends; Claude Code reports the marker. - **Send Annotations** → feedback on stdout → Claude Code re-prompts with the feedback. - **Close** → empty stdout → turn ends. From 97df41d20f9be3527a4602a35ae9cd37c35426eb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 15:03:27 -0700 Subject: [PATCH 25/26] feat(annotate): add --silent-approve flag for naive hook compatibility Hooks that treat any non-empty stdout as a block signal (spec-kit PostToolUse pattern) need Approve to emit empty stdout. The default --gate behavior emits "The user approved." so slash command templates can distinguish approve from close in plaintext. --silent-approve suppresses that marker, restoring silence-is-permission for hook authors. No effect in --json mode. Includes: shared parser update (FLAG_MAP refactor), 4 new tests, docs for annotate, annotate-last, hook-integration, and gates guide. For provenance purposes, this commit was AI assisted. --- apps/hook/server/index.ts | 16 +++-- .../content/docs/commands/annotate-last.md | 2 +- .../src/content/docs/commands/annotate.md | 13 +++- .../annotate-gates-and-json-responses.md | 34 +++++++--- .../content/docs/guides/hook-integration.md | 27 ++++++-- bun.lock | 6 +- packages/shared/annotate-args.test.ts | 68 +++++++++++++++++++ packages/shared/annotate-args.ts | 40 ++++++----- 8 files changed, 163 insertions(+), 43 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 67287030..8c9914b1 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -126,21 +126,29 @@ const cliNoJina = noJinaIdx !== -1; if (cliNoJina) args.splice(noJinaIdx, 1); // Annotate review-gate flags (#570): --gate adds an Approve button, -// --json switches stdout to structured decision output. +// --json switches stdout to structured decision output, --silent-approve +// suppresses the plaintext approve marker (naive hooks that treat any +// stdout as a block signal opt in here to keep silence-is-permission). const gateIdx = args.indexOf("--gate"); const gateFlag = gateIdx !== -1; if (gateFlag) args.splice(gateIdx, 1); const jsonIdx = args.indexOf("--json"); const jsonFlag = jsonIdx !== -1; if (jsonFlag) args.splice(jsonIdx, 1); +const silentApproveIdx = args.indexOf("--silent-approve"); +const silentApproveFlag = silentApproveIdx !== -1; +if (silentApproveFlag) args.splice(silentApproveIdx, 1); // Stdout matrix for annotate / annotate-last / copilot annotate-last (#570). // Plaintext mode: // - Close emits empty stdout (naive PostToolUse / Stop hooks: empty = allow). // - Approve emits "The user approved." so agents and templates can -// distinguish approval from close without needing --json. +// distinguish approval from close without needing --json. With +// --silent-approve, Approve also emits empty stdout (hook-friendly). // - Send Annotations emits the plaintext feedback markdown. -// --json switches to structured output across all three decisions. +// --json switches to structured output across all three decisions; +// --silent-approve has no effect in --json mode (JSON always routes by +// decision field, so there's no ambiguity to silence). export const APPROVED_PLAINTEXT_MARKER = "The user approved."; function emitAnnotateOutcome(result: { @@ -160,7 +168,7 @@ function emitAnnotateOutcome(result: { } if (result.exit) return; // empty stdout on close if (result.approved) { - console.log(APPROVED_PLAINTEXT_MARKER); + if (!silentApproveFlag) console.log(APPROVED_PLAINTEXT_MARKER); return; } if (result.feedback) console.log(result.feedback); diff --git a/apps/marketing/src/content/docs/commands/annotate-last.md b/apps/marketing/src/content/docs/commands/annotate-last.md index aef48995..d4fc02f1 100644 --- a/apps/marketing/src/content/docs/commands/annotate-last.md +++ b/apps/marketing/src/content/docs/commands/annotate-last.md @@ -75,7 +75,7 @@ The annotation UI in `annotate-last` mode works the same as `/plannotator-annota ## Flags -`plannotator annotate-last` accepts the same `--gate` and `--json` flags as `plannotator annotate`. See [Annotate → Flags](/docs/commands/annotate/#flags) for the full matrix. +`plannotator annotate-last` accepts the same `--gate`, `--json`, and `--silent-approve` flags as `plannotator annotate`. See [Annotate → Flags](/docs/commands/annotate/#flags) for the full matrix. The common use case for `--gate` on annotate-last is a turn-by-turn review gate wired to a Stop hook: diff --git a/apps/marketing/src/content/docs/commands/annotate.md b/apps/marketing/src/content/docs/commands/annotate.md index 222b66d6..beab8ab7 100644 --- a/apps/marketing/src/content/docs/commands/annotate.md +++ b/apps/marketing/src/content/docs/commands/annotate.md @@ -103,7 +103,7 @@ All annotation types work identically: deletions, replacements, comments, insert ## Flags -Two opt-in flags turn annotate into a review gate for hook integrations (spec-driven frameworks, turn-by-turn review, and so on). They are orthogonal: you can use either alone or combine them. +Three opt-in flags turn annotate into a review gate for hook integrations (spec-driven frameworks, turn-by-turn review, and so on). They compose: use any alone or combine them. ### `--gate` @@ -123,18 +123,25 @@ Switches stdout to a structured decision object so hooks can route programmatica `feedback` is only present when `decision === "annotated"`. +### `--silent-approve` + +Suppresses the plaintext approve marker so Approve emits empty stdout instead of `The user approved.`. Use this with naive hooks that treat any non-empty stdout as a block signal. Approve and Close both become silent, and only Send Annotations blocks with feedback (silence-is-permission). + +`--silent-approve` only affects plaintext mode. In `--json` mode, Approve continues to emit `{"decision":"approved"}` — JSON callers route on the `decision` field, so there's no ambiguity to silence. + ### Stdout matrix | Flags | UX | Approve | Close | Send Annotations | |---|---|---|---|---| | *(none)* | 2-button | n/a | empty | feedback (plaintext) | | `--gate` | 3-button | `The user approved.` | empty | feedback (plaintext) | +| `--gate --silent-approve` | 3-button | empty | empty | feedback (plaintext) | | `--json` | 2-button | n/a | `{"decision":"dismissed"}` | `{"decision":"annotated","feedback":"..."}` | | `--gate --json` | 3-button | `{"decision":"approved"}` | `{"decision":"dismissed"}` | `{"decision":"annotated","feedback":"..."}` | -**Key property:** `--gate` plaintext output is unambiguous across all three decisions. Close is empty, Send Annotations is feedback markdown, Approve is the exact line `The user approved.` — each case distinguishable without JSON parsing. Use `--json` when you want machine-readable decision objects instead of string matching. +**Key property:** `--gate` plaintext output is unambiguous across three decisions — Close is empty, Send Annotations is feedback markdown, Approve is the line `The user approved.`. Drop the marker with `--silent-approve` when your hook treats any stdout as a block. Use `--json` when you want machine-readable decision objects instead of string matching. -On OpenCode and Pi, `--json` is silently accepted because those harnesses write back into the session directly rather than via stdout. The `--gate` flag behaves identically across all three harnesses. +On OpenCode and Pi, `--json` and `--silent-approve` are silently accepted because those harnesses write back into the session directly rather than via stdout. The `--gate` flag behaves identically across all three harnesses. See [Hook integration recipes](/docs/guides/hook-integration/) for ready-to-use PostToolUse and Stop hook examples. diff --git a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md index 77632d87..1f0a71e5 100644 --- a/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md +++ b/apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md @@ -1,29 +1,31 @@ --- title: "Annotate Gates and JSON Responses" -description: "The --gate and --json flags extend plannotator annotate from a feedback tool into a structured review gate with machine-readable decisions. Use them to wire Plannotator into spec-driven workflows, Stop hooks, and agent pipelines." +description: "The --gate, --json, and --silent-approve flags extend plannotator annotate from a feedback tool into a structured review gate with machine-readable decisions. Use them to wire Plannotator into spec-driven workflows, Stop hooks, and agent pipelines." sidebar: order: 28 section: "Guides" --- -`plannotator annotate` and `plannotator annotate-last` accept two flags that turn markdown annotation into a full review gate with structured output. +`plannotator annotate` and `plannotator annotate-last` accept three flags that turn markdown annotation into a full review gate with structured output. ## Capabilities - **`--gate`** adds an Approve button to the annotation UI. The reviewer picks one of three decisions: approve, send annotations, or close. - **`--json`** emits every decision as a structured JSON object on stdout so hooks and plugins can route on the outcome without parsing free text. -- The flags compose. Use them together, separately, or not at all. +- **`--silent-approve`** suppresses the plaintext approve marker so Approve emits empty stdout. Naive hooks that treat any stdout as a block signal opt in here to keep silence-is-permission intact. +- The flags compose. Use any alone or together. - Identical semantics across every supported harness: Claude Code, Copilot CLI, Gemini CLI, OpenCode, Pi, and Codex. ## Stdout contract ``` - Flags │ UX │ Approve │ Close │ Annotate -─────────────── ┼──────────────────┼─────────────────────────┼──────────────────────────┼─────────────────────────────────────────────── - (none) │ 2-button │ n/a │ empty │ feedback (plaintext) - --gate │ 3-button │ `The user approved.` │ empty │ feedback (plaintext) - --json │ 2-button │ n/a │ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} - --gate --json │ 3-button │ {"decision":"approved"}│ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} + Flags │ UX │ Approve │ Close │ Annotate +──────────────────────────┼──────────────────┼─────────────────────────┼──────────────────────────┼─────────────────────────────────────────────── + (none) │ 2-button │ n/a │ empty │ feedback (plaintext) + --gate │ 3-button │ `The user approved.` │ empty │ feedback (plaintext) + --gate --silent-approve │ 3-button │ empty │ empty │ feedback (plaintext) + --json │ 2-button │ n/a │ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} + --gate --json │ 3-button │ {"decision":"approved"}│ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} ``` ### JSON schema @@ -68,7 +70,7 @@ A three-way review decision. The annotation UI adds an Approve button alongside - **Send Annotations.** The reviewer has specific changes. The feedback is returned verbatim. - **Close.** The session ends without a decision. Neither a signal to the agent nor an instruction set. -In plaintext mode, Approve emits the single line `The user approved.` on stdout so templates and agents can distinguish approval from close without needing `--json`. Close emits nothing. Send Annotations emits the feedback markdown. Hook authors who treat any non-empty stdout as a block signal need to filter the approve marker (or use `--json` for cleaner routing). +In plaintext mode, Approve emits the single line `The user approved.` on stdout so templates and agents can distinguish approval from close without needing `--json`. Close emits nothing. Send Annotations emits the feedback markdown. Hook authors who treat any non-empty stdout as a block signal can add `--silent-approve` to suppress the marker, or use `--json` for structured routing. ## `--json` @@ -80,6 +82,18 @@ Structured stdout. Every decision is emitted as a JSON object with a `decision` - `--gate --json` unlocks all three decisions in structured form. - On OpenCode and Pi, `--json` is accepted silently. Those harnesses write back to the session directly rather than via stdout, so the flag has no effect there. Recipes remain portable. +## `--silent-approve` + +Suppresses the plaintext approve marker. With `--gate --silent-approve`, Approve emits empty stdout (instead of `The user approved.`), matching Close. Send Annotations still emits feedback. + +This is the shape naive hooks want — the ones that treat any non-empty stdout as a block signal: + +- Approve → empty → hook passes → agent proceeds. +- Close → empty → hook passes → agent proceeds. +- Send Annotations → feedback → hook blocks with that feedback as the reason. + +`--silent-approve` only affects plaintext mode. In `--json` mode, Approve still emits `{"decision":"approved"}` — JSON callers route on the `decision` field, so there's no ambiguity to silence. The flag is accepted silently on OpenCode and Pi for the same reason `--json` is: those harnesses don't use stdout as the signal channel. + ## Primary use cases ### Spec-driven development frameworks diff --git a/apps/marketing/src/content/docs/guides/hook-integration.md b/apps/marketing/src/content/docs/guides/hook-integration.md index b79c88f1..dc1150b9 100644 --- a/apps/marketing/src/content/docs/guides/hook-integration.md +++ b/apps/marketing/src/content/docs/guides/hook-integration.md @@ -6,13 +6,14 @@ sidebar: section: "Guides" --- -The `--gate` and `--json` flags on `plannotator annotate` and `plannotator annotate-last` turn annotation into a structured review decision. This guide shows how to wire them into agent hooks so a human can gate the agent at specific points in a workflow. +The `--gate`, `--json`, and `--silent-approve` flags on `plannotator annotate` and `plannotator annotate-last` turn annotation into a structured review decision. This guide shows how to wire them into agent hooks so a human can gate the agent at specific points in a workflow. See [Annotate → Flags](/docs/commands/annotate/#flags) for the full stdout matrix. The short version: - `--gate` adds a three-button UX (Approve / Send Annotations / Close). -- Without `--json`: Approve emits the line `The user approved.`, Close emits empty stdout, Send Annotations emits the feedback markdown. Three distinguishable outputs without parsing JSON. -- With `--json`: every decision emits a structured `{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." }` object. +- Plaintext default: Approve emits the line `The user approved.`, Close emits empty stdout, Send Annotations emits the feedback markdown. Three distinguishable outputs without parsing JSON. +- `--silent-approve` collapses Approve to empty stdout, matching Close. Use this with naive "any stdout = block" hooks so silence means permission. +- `--json` emits every decision as a structured `{ "decision": "approved" | "annotated" | "dismissed", "feedback": "..." }` object. ## Recipe 1: PostToolUse spec gate @@ -47,7 +48,21 @@ Behavior: - **Send Annotations** → feedback markdown on stdout. Claude Code reports the feedback back. - **Close** → empty stdout. Claude Code proceeds silently. -If your hook treats any non-empty stdout as a block signal (some scripts do), filter the approve marker explicitly, or use the `--json` recipe below to route on the parsed decision instead. +### Silence-is-permission (`--silent-approve`) + +If your hook treats any non-empty stdout as a block signal (spec-kit and similar naive PostToolUse hooks), add `--silent-approve` so Approve also emits empty stdout: + +```json +"command": "plannotator annotate \"$CLAUDE_TOOL_INPUT_file_path\" --gate --silent-approve" +``` + +Behavior with the flag: + +- **Approve** → empty stdout → hook passes → agent proceeds. +- **Close** → empty stdout → hook passes → agent proceeds. +- **Send Annotations** → feedback on stdout → hook blocks with feedback as the reason. + +Approve and Close collapse into the same "silent = allow" cell, which is what this class of hook expects. Only Send Annotations carries content the agent needs to react to. ### Structured (`--json`) @@ -104,6 +119,8 @@ Behavior: - **Send Annotations** → feedback on stdout → Claude Code re-prompts with the feedback. - **Close** → empty stdout → turn ends. +Add `--silent-approve` if your Stop hook treats any stdout as a re-prompt trigger — Approve then emits empty stdout too, so only Send Annotations re-fires the turn with feedback. + ### Structured Same pattern as the PostToolUse recipe — pipe `--gate --json` through a shell wrapper if you want distinct handling per decision. @@ -116,7 +133,7 @@ The same `--gate` flag works in OpenCode's `/plannotator-annotate` and Pi's `/pl /plannotator-annotate spec.md --gate ``` -On those harnesses there is no stdout channel back to the agent — the plugin writes back via `session.prompt` (OpenCode) or `sendUserMessage` (Pi). Approve and Close both result in no session injection; Send Annotations injects the feedback. `--json` is accepted silently on these harnesses so recipes stay portable. +On those harnesses there is no stdout channel back to the agent — the plugin writes back via `session.prompt` (OpenCode) or `sendUserMessage` (Pi). Approve and Close both result in no session injection; Send Annotations injects the feedback. `--json` and `--silent-approve` are accepted silently on these harnesses so recipes stay portable. Third-party Pi or OpenCode plugins that want explicit decision routing can read `approved` directly from the server's decision object: diff --git a/bun.lock b/bun.lock index 2ff0a627..c87c0427 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.18.0", + "version": "0.19.0", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.18.0", + "version": "0.19.0", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "turndown": "^7.2.4", @@ -186,7 +186,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.18.0", + "version": "0.19.0", "dependencies": { "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts index 884a0133..ad566972 100644 --- a/packages/shared/annotate-args.test.ts +++ b/packages/shared/annotate-args.test.ts @@ -8,6 +8,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "spec.md", gate: false, json: false, + silentApprove: false, }); }); @@ -17,6 +18,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "spec.md", gate: true, json: false, + silentApprove: false, }); }); @@ -26,6 +28,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "spec.md", gate: true, json: false, + silentApprove: false, }); }); @@ -35,6 +38,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "spec.md", gate: true, json: true, + silentApprove: false, }); }); @@ -44,6 +48,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "", gate: true, json: true, + silentApprove: false, }); }); @@ -53,6 +58,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "my file.md", gate: true, json: false, + silentApprove: false, }); }); @@ -68,6 +74,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "@spec.md", gate: true, json: false, + silentApprove: false, }); }); @@ -77,6 +84,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "@plannotator/ui/README.md", gate: false, json: false, + silentApprove: false, }); }); @@ -86,6 +94,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "@docs/spec.md", gate: true, json: true, + silentApprove: false, }); }); @@ -95,6 +104,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "https://example.com/docs", gate: true, json: false, + silentApprove: false, }); }); @@ -104,6 +114,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "spec.md", gate: true, json: false, + silentApprove: false, }); }); @@ -113,6 +124,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "", gate: false, json: false, + silentApprove: false, }); }); @@ -122,6 +134,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "", gate: false, json: false, + silentApprove: false, }); }); @@ -131,6 +144,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "./specs/", gate: true, json: true, + silentApprove: false, }); }); @@ -146,6 +160,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "My Notes.md", gate: true, json: false, + silentApprove: false, }); }); @@ -155,6 +170,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "My Notes.md", gate: true, json: false, + silentApprove: false, }); }); @@ -164,6 +180,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "My\tNotes.md", gate: true, json: false, + silentApprove: false, }); }); @@ -173,6 +190,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "/tmp/My Notes.md", gate: false, json: false, + silentApprove: false, }); }); @@ -187,6 +205,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "@foo.md", gate: true, json: false, + silentApprove: false, }); }); @@ -196,6 +215,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "@foo.md", gate: true, json: false, + silentApprove: false, }); }); @@ -205,6 +225,7 @@ describe("parseAnnotateArgs", () => { rawFilePath: "@My Notes.md", gate: true, json: false, + silentApprove: false, }); }); @@ -214,6 +235,53 @@ describe("parseAnnotateArgs", () => { rawFilePath: "My Notes.md", gate: false, json: false, + silentApprove: false, + }); + }); + + // --silent-approve (issue #570 follow-up) opts the plaintext Approve out of + // emitting the "The user approved." marker. It parses identically to the + // other flags and is recognized in any position, including without --gate + // (where it's a documented no-op — the parser still strips it so it doesn't + // leak into the path). + + test("--silent-approve alongside --gate", () => { + expect(parseAnnotateArgs("spec.md --gate --silent-approve")).toEqual({ + filePath: "spec.md", + rawFilePath: "spec.md", + gate: true, + json: false, + silentApprove: true, + }); + }); + + test("--silent-approve with all three flags", () => { + expect(parseAnnotateArgs("spec.md --gate --json --silent-approve")).toEqual({ + filePath: "spec.md", + rawFilePath: "spec.md", + gate: true, + json: true, + silentApprove: true, + }); + }); + + test("--silent-approve alone is stripped from path (no-op but not leaked)", () => { + expect(parseAnnotateArgs("spec.md --silent-approve")).toEqual({ + filePath: "spec.md", + rawFilePath: "spec.md", + gate: false, + json: false, + silentApprove: true, + }); + }); + + test("--silent-approve before path", () => { + expect(parseAnnotateArgs("--silent-approve --gate spec.md")).toEqual({ + filePath: "spec.md", + rawFilePath: "spec.md", + gate: true, + json: false, + silentApprove: true, }); }); }); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index 5f564992..0658a34d 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -1,11 +1,11 @@ /** * Parse CLI-style args arriving as a single whitespace-delimited string. * - * Extracts the `--gate` and `--json` flags (issue #570) from the remainder, - * which is treated as the target path. Leading `@` is stripped via the - * shared at-reference helper — reference-mode is primary. Scoped-package- - * style literal `@` paths are handled by a fallback that the downstream - * resolver opts into (see at-reference.ts). + * Extracts the `--gate`, `--json`, and `--silent-approve` flags (issue #570) + * from the remainder, which is treated as the target path. Leading `@` is + * stripped via the shared at-reference helper — reference-mode is primary. + * Scoped-package-style literal `@` paths are handled by a fallback that the + * downstream resolver opts into (see at-reference.ts). * * Used by the OpenCode plugin and Pi extension, where the whole args string * arrives pre-joined from the harness slash-command dispatcher. The Claude @@ -13,16 +13,16 @@ * this helper. * * Implementation: walks the raw string once, preserving whitespace runs and - * non-whitespace tokens as separate segments. Only `--gate` / `--json` - * tokens (whole-word match) plus one adjacent whitespace run are removed. + * non-whitespace tokens as separate segments. Only known flag tokens + * (whole-word match) plus one adjacent whitespace run are removed. * This keeps double-spaces and tabs inside file paths intact — which * matches the pre-PR behavior on `main`, where OpenCode and Pi passed * the raw args string straight through to the filesystem resolver. * - * Remaining edge: if a path literally contains `--gate` or `--json` as a - * standalone whitespace-separated token (e.g. `"Feature --gate spec.md"`), - * that token is stripped. Supporting this would need shell-style quoting, - * which isn't worth the complexity for a vanishingly rare naming pattern. + * Remaining edge: if a path literally contains a known flag as a standalone + * whitespace-separated token (e.g. `"Feature --gate spec.md"`), that token + * is stripped. Supporting this would need shell-style quoting, which isn't + * worth the complexity for a vanishingly rare naming pattern. */ import { stripAtPrefix } from "./at-reference"; @@ -42,14 +42,20 @@ export interface ParsedAnnotateArgs { rawFilePath: string; gate: boolean; json: boolean; + silentApprove: boolean; } type Segment = { type: "ws" | "tok"; text: string }; +const FLAG_MAP = { + "--gate": "gate", + "--json": "json", + "--silent-approve": "silentApprove", +} as const satisfies Record>; + export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { const s = (raw ?? "").trim(); - let gate = false; - let json = false; + const flags = { gate: false, json: false, silentApprove: false }; const segments: Segment[] = []; for (let i = 0; i < s.length;) { @@ -63,10 +69,10 @@ export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { for (let j = 0; j < segments.length; j++) { const seg = segments[j]; if (seg.type !== "tok") continue; - if (seg.text !== "--gate" && seg.text !== "--json") continue; + const key = FLAG_MAP[seg.text as keyof typeof FLAG_MAP]; + if (!key) continue; - if (seg.text === "--gate") gate = true; - else json = true; + flags[key] = true; keep[j] = false; // Drop one adjacent whitespace run so removed flags don't leave dangling @@ -91,5 +97,5 @@ export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { .trim(), ); - return { filePath: stripAtPrefix(rawFilePath), rawFilePath, gate, json }; + return { filePath: stripAtPrefix(rawFilePath), rawFilePath, ...flags }; } From a465af377f6404e5bf499dfeb5c518c736249bdf Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 23 Apr 2026 16:49:28 -0700 Subject: [PATCH 26/26] fix(hook): list --gate, --json, --silent-approve in usage and --help For provenance purposes, this commit was AI assisted. --- apps/hook/server/cli.ts | 2 +- apps/hook/server/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index b39ef544..5b20ad8f 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -15,7 +15,7 @@ export function formatTopLevelHelp(): string { " plannotator --help", " plannotator [--browser ]", " plannotator review [PR_URL]", - " plannotator annotate [--no-jina]", + " plannotator annotate [--no-jina] [--gate] [--json] [--silent-approve]", " plannotator last", " plannotator archive", " plannotator sessions", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 8c9914b1..ace64c0c 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -527,7 +527,7 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--no-jina]"); + console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--silent-approve]"); process.exit(1); }