-
Notifications
You must be signed in to change notification settings - Fork 327
feat(annotate): approve/annotate/dismiss flow (#570) #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4b2a37d
a053e0d
f287c94
384e5d8
94b0144
87e651a
8d17c47
47a9d24
61193ea
ba49e86
b8821ad
a8b4458
cf87eb8
a834238
eed859e
223c671
01b87c3
42ef6ea
127ce81
9db8259
ab54dba
9f13f25
a155c6c
acb8053
97df41d
a465af3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,8 +5,12 @@ 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. | ||||||||||||
| The output above will be one of: | ||||||||||||
|
|
||||||||||||
| 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. | ||||||||||||
|
Comment on lines
+15
to
+16
|
||||||||||||
| 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. | |
| 2. 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. Empty. This may mean the user approved silently, or that they closed the session without requesting changes. Acknowledge neutrally with a single sentence ("Acknowledged.") and stop. Do not begin any work. | |
| 4. 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. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -10,4 +10,8 @@ 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. | ||||||
| The output above will be one of: | ||||||
|
|
||||||
| 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. | ||||||
|
||||||
| 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. | |
| 2. Empty, OR a JSON object with `"decision": "dismissed"`. Empty output is ambiguous: the user may have closed the session without requesting changes, or approval may have been recorded silently. Do not infer which occurred. Acknowledge with a single neutral sentence ("No further action needed.") and stop. Do not begin any work. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, 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"; | ||
|
|
@@ -124,6 +125,55 @@ 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, --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. 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; | ||
| // --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: { | ||
| 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.exit) return; // empty stdout on close | ||
| if (result.approved) { | ||
| if (!silentApproveFlag) console.log(APPROVED_PLAINTEXT_MARKER); | ||
| return; | ||
|
Comment on lines
+169
to
+172
|
||
| } | ||
| if (result.feedback) console.log(result.feedback); | ||
| } | ||
|
|
||
| if (isTopLevelHelpInvocation(args)) { | ||
| console.log(formatTopLevelHelp()); | ||
| process.exit(0); | ||
|
|
@@ -475,16 +525,16 @@ if (args[0] === "sessions") { | |
| // ANNOTATE MODE | ||
| // ============================================ | ||
|
|
||
| let filePath = args[1]; | ||
| if (!filePath) { | ||
| console.error("Usage: plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina]"); | ||
| const rawFilePath = args[1]; | ||
| if (!rawFilePath) { | ||
| console.error("Usage: plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json] [--silent-approve]"); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| // Strip @ prefix if present (Claude Code file reference syntax) | ||
| if (filePath.startsWith("@")) { | ||
| filePath = filePath.slice(1); | ||
| } | ||
| // 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(); | ||
|
|
@@ -519,16 +569,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}`); | ||
|
|
@@ -539,41 +587,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}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -590,6 +646,7 @@ if (args[0] === "sessions") { | |
| sharingEnabled, | ||
| shareBaseUrl, | ||
| pasteApiUrl, | ||
| gate: gateFlag, | ||
| htmlContent: planHtmlContent, | ||
| onReady: async (url, isRemote, port) => { | ||
| handleAnnotateServerReady(url, isRemote, port); | ||
|
|
@@ -622,11 +679,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 +777,7 @@ if (args[0] === "sessions") { | |
| sharingEnabled, | ||
| shareBaseUrl, | ||
| pasteApiUrl, | ||
| gate: gateFlag, | ||
| htmlContent: planHtmlContent, | ||
| onReady: async (url, isRemote, port) => { | ||
| handleAnnotateServerReady(url, isRemote, port); | ||
|
|
@@ -750,11 +804,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 +965,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 +990,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") { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.