Skip to content

Commit d102c5f

Browse files
authored
feat(annotate): add --gate, --json, and --silent-approve flags (#570)
Adds an opt-in review gate flow to annotation mode with three composable flags: - `--gate`: 3-way UX (Approve / Send Annotations / Close) - `--json`: structured decision output (`{"decision":"approved|annotated|dismissed"}`) - `--silent-approve`: suppresses plaintext approve marker for naive hooks Includes shared arg parser, @-reference handling, updated templates across all harnesses (Claude Code, Copilot, Gemini, OpenCode, Pi), and full documentation. Closes #570 For provenance purposes, this commit was AI assisted.
1 parent 68972aa commit d102c5f

27 files changed

Lines changed: 1177 additions & 118 deletions

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,10 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
257257

258258
| Endpoint | Method | Purpose |
259259
| --------------------- | ------ | ------------------------------------------ |
260-
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo? }` |
260+
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate }` |
261261
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
262+
| `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) |
263+
| `/api/exit` | POST | Close session without feedback |
262264
| `/api/image` | GET | Serve image by path query param |
263265
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
264266
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |

apps/copilot/commands/plannotator-annotate.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ allowed-tools: shell(plannotator:*)
99

1010
## Your task
1111

12-
Address the annotation feedback above. The user has reviewed the markdown file and provided specific annotations and comments.
12+
The output above will be one of:
13+
14+
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.
15+
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.
16+
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.

apps/copilot/commands/plannotator-last.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ allowed-tools: shell(plannotator:*)
55

66
## Message Annotations
77

8-
!`plannotator copilot-last`
8+
!`plannotator copilot-last $ARGUMENTS`
99

1010
## Your task
1111

12-
Address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments.
12+
The output above will be one of:
13+
14+
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.
15+
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.
16+
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.

apps/gemini/commands/plannotator-annotate.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,9 @@ prompt = """
66
77
## Your task
88
9-
Address the annotation feedback above. The user has reviewed the markdown file and provided specific annotations and comments.
9+
The output above will be one of:
10+
11+
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.
12+
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.
13+
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.
1014
"""

apps/hook/commands/plannotator-annotate.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ disable-model-invocation: true
1010

1111
## Your task
1212

13-
Address the annotation feedback above. The user has reviewed the markdown file(s) and provided specific annotations and comments.
13+
The output above will be one of:
14+
15+
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.
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.
17+
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.

apps/hook/commands/plannotator-last.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ disable-model-invocation: true
66

77
## Message Annotations
88

9-
!`plannotator annotate-last`
9+
!`plannotator annotate-last $ARGUMENTS`
1010

1111
## Your task
1212

13-
Address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments.
13+
The output above will be one of:
14+
15+
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.
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.
17+
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.

apps/hook/server/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function formatTopLevelHelp(): string {
1515
" plannotator --help",
1616
" plannotator [--browser <name>]",
1717
" plannotator review [PR_URL]",
18-
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina]",
18+
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json] [--silent-approve]",
1919
" plannotator last",
2020
" plannotator archive",
2121
" plannotator sessions",

apps/hook/server/index.ts

Lines changed: 110 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
} from "@plannotator/server/annotate";
6666
import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs";
6767
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
68+
import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference";
6869
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
6970
import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
7071
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree";
@@ -124,6 +125,55 @@ const noJinaIdx = args.indexOf("--no-jina");
124125
const cliNoJina = noJinaIdx !== -1;
125126
if (cliNoJina) args.splice(noJinaIdx, 1);
126127

128+
// Annotate review-gate flags (#570): --gate adds an Approve button,
129+
// --json switches stdout to structured decision output, --silent-approve
130+
// suppresses the plaintext approve marker (naive hooks that treat any
131+
// stdout as a block signal opt in here to keep silence-is-permission).
132+
const gateIdx = args.indexOf("--gate");
133+
const gateFlag = gateIdx !== -1;
134+
if (gateFlag) args.splice(gateIdx, 1);
135+
const jsonIdx = args.indexOf("--json");
136+
const jsonFlag = jsonIdx !== -1;
137+
if (jsonFlag) args.splice(jsonIdx, 1);
138+
const silentApproveIdx = args.indexOf("--silent-approve");
139+
const silentApproveFlag = silentApproveIdx !== -1;
140+
if (silentApproveFlag) args.splice(silentApproveIdx, 1);
141+
142+
// Stdout matrix for annotate / annotate-last / copilot annotate-last (#570).
143+
// Plaintext mode:
144+
// - Close emits empty stdout (naive PostToolUse / Stop hooks: empty = allow).
145+
// - Approve emits "The user approved." so agents and templates can
146+
// distinguish approval from close without needing --json. With
147+
// --silent-approve, Approve also emits empty stdout (hook-friendly).
148+
// - Send Annotations emits the plaintext feedback markdown.
149+
// --json switches to structured output across all three decisions;
150+
// --silent-approve has no effect in --json mode (JSON always routes by
151+
// decision field, so there's no ambiguity to silence).
152+
export const APPROVED_PLAINTEXT_MARKER = "The user approved.";
153+
154+
function emitAnnotateOutcome(result: {
155+
feedback: string;
156+
exit?: boolean;
157+
approved?: boolean;
158+
}): void {
159+
if (jsonFlag) {
160+
if (result.approved) {
161+
console.log(JSON.stringify({ decision: "approved" }));
162+
} else if (result.exit) {
163+
console.log(JSON.stringify({ decision: "dismissed" }));
164+
} else {
165+
console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" }));
166+
}
167+
return;
168+
}
169+
if (result.exit) return; // empty stdout on close
170+
if (result.approved) {
171+
if (!silentApproveFlag) console.log(APPROVED_PLAINTEXT_MARKER);
172+
return;
173+
}
174+
if (result.feedback) console.log(result.feedback);
175+
}
176+
127177
if (isTopLevelHelpInvocation(args)) {
128178
console.log(formatTopLevelHelp());
129179
process.exit(0);
@@ -475,16 +525,16 @@ if (args[0] === "sessions") {
475525
// ANNOTATE MODE
476526
// ============================================
477527

478-
let filePath = args[1];
479-
if (!filePath) {
480-
console.error("Usage: plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina]");
528+
const rawFilePath = args[1];
529+
if (!rawFilePath) {
530+
console.error("Usage: plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json] [--silent-approve]");
481531
process.exit(1);
482532
}
483533

484-
// Strip @ prefix if present (Claude Code file reference syntax)
485-
if (filePath.startsWith("@")) {
486-
filePath = filePath.slice(1);
487-
}
534+
// Primary resolution strips the `@` reference marker; rawFilePath is
535+
// preserved so each branch can fall back to the literal form below
536+
// (scoped-package-style names).
537+
let filePath = stripAtPrefix(rawFilePath);
488538

489539
// Use PLANNOTATOR_CWD if set (original working directory before script cd'd)
490540
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
@@ -519,16 +569,14 @@ if (args[0] === "sessions") {
519569
absolutePath = filePath; // Use URL as the "path" for display
520570
sourceInfo = filePath; // Full URL for source attribution
521571
} else {
522-
// Check if the argument is a directory (folder annotation mode)
523-
const resolvedArg = resolveUserPath(filePath, projectRoot);
524-
let isFolder = false;
525-
try {
526-
isFolder = statSync(resolvedArg).isDirectory();
527-
} catch {
528-
// Not a directory, fall through to file resolution
529-
}
530-
531-
if (isFolder) {
572+
// Folder check with literal-@ fallback for scoped-package-style names.
573+
const folderCandidate = resolveAtReference(rawFilePath, (c) => {
574+
try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); }
575+
catch { return false; }
576+
});
577+
578+
if (folderCandidate !== null) {
579+
const resolvedArg = resolveUserPath(folderCandidate, projectRoot);
532580
// Folder annotation mode (markdown + HTML files)
533581
if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) {
534582
console.error(`No markdown or HTML files found in ${resolvedArg}`);
@@ -539,41 +587,49 @@ if (args[0] === "sessions") {
539587
markdown = "";
540588
annotateMode = "annotate-folder";
541589
console.error(`Folder: ${resolvedArg}`);
542-
} else if (/\.html?$/i.test(resolvedArg)) {
543-
// HTML file annotation mode — convert to markdown via Turndown
544-
if (!existsSync(resolvedArg)) {
545-
console.error(`File not found: ${filePath}`);
546-
process.exit(1);
547-
}
548-
const htmlFile = Bun.file(resolvedArg);
549-
if (htmlFile.size > 10 * 1024 * 1024) {
550-
console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`);
551-
process.exit(1);
552-
}
553-
const html = await htmlFile.text();
554-
markdown = htmlToMarkdown(html);
555-
absolutePath = resolvedArg;
556-
sourceInfo = path.basename(resolvedArg);
557-
console.error(`Converted: ${absolutePath}`);
558590
} else {
559-
// Single markdown file annotation mode
560-
const resolved = resolveMarkdownFile(filePath, projectRoot);
591+
// HTML check with the same literal-@ fallback semantics.
592+
const htmlCandidate = resolveAtReference(rawFilePath, (c) => {
593+
const abs = resolveUserPath(c, projectRoot);
594+
return /\.html?$/i.test(abs) && existsSync(abs);
595+
});
596+
597+
if (htmlCandidate !== null) {
598+
const resolvedArg = resolveUserPath(htmlCandidate, projectRoot);
599+
const htmlFile = Bun.file(resolvedArg);
600+
if (htmlFile.size > 10 * 1024 * 1024) {
601+
console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`);
602+
process.exit(1);
603+
}
604+
const html = await htmlFile.text();
605+
markdown = htmlToMarkdown(html);
606+
absolutePath = resolvedArg;
607+
sourceInfo = path.basename(resolvedArg);
608+
console.error(`Converted: ${absolutePath}`);
609+
} else {
610+
// Single markdown file annotation mode
611+
// Strip-first with literal-@ fallback (scoped-package-style names).
612+
let resolved = resolveMarkdownFile(filePath, projectRoot);
613+
if (resolved.kind === "not_found" && rawFilePath !== filePath) {
614+
resolved = resolveMarkdownFile(rawFilePath, projectRoot);
615+
}
561616

562-
if (resolved.kind === "ambiguous") {
563-
console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`);
564-
for (const match of resolved.matches) {
565-
console.error(` ${match}`);
617+
if (resolved.kind === "ambiguous") {
618+
console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`);
619+
for (const match of resolved.matches) {
620+
console.error(` ${match}`);
621+
}
622+
process.exit(1);
623+
}
624+
if (resolved.kind === "not_found") {
625+
console.error(`File not found: ${resolved.input}`);
626+
process.exit(1);
566627
}
567-
process.exit(1);
568-
}
569-
if (resolved.kind === "not_found") {
570-
console.error(`File not found: ${resolved.input}`);
571-
process.exit(1);
572-
}
573628

574-
absolutePath = resolved.path;
575-
markdown = await Bun.file(absolutePath).text();
576-
console.error(`Resolved: ${absolutePath}`);
629+
absolutePath = resolved.path;
630+
markdown = await Bun.file(absolutePath).text();
631+
console.error(`Resolved: ${absolutePath}`);
632+
}
577633
}
578634
}
579635

@@ -590,6 +646,7 @@ if (args[0] === "sessions") {
590646
sharingEnabled,
591647
shareBaseUrl,
592648
pasteApiUrl,
649+
gate: gateFlag,
593650
htmlContent: planHtmlContent,
594651
onReady: async (url, isRemote, port) => {
595652
handleAnnotateServerReady(url, isRemote, port);
@@ -622,11 +679,7 @@ if (args[0] === "sessions") {
622679
server.stop();
623680

624681
// Output feedback (captured by slash command)
625-
if (result.exit) {
626-
console.log("Annotation session closed without feedback.");
627-
} else {
628-
console.log(result.feedback || "No feedback provided.");
629-
}
682+
emitAnnotateOutcome(result);
630683
process.exit(0);
631684

632685
} else if (args[0] === "annotate-last" || args[0] === "last") {
@@ -724,6 +777,7 @@ if (args[0] === "sessions") {
724777
sharingEnabled,
725778
shareBaseUrl,
726779
pasteApiUrl,
780+
gate: gateFlag,
727781
htmlContent: planHtmlContent,
728782
onReady: async (url, isRemote, port) => {
729783
handleAnnotateServerReady(url, isRemote, port);
@@ -750,11 +804,7 @@ if (args[0] === "sessions") {
750804

751805
server.stop();
752806

753-
if (result.exit) {
754-
console.log("Annotation session closed without feedback.");
755-
} else {
756-
console.log(result.feedback || "No feedback provided.");
757-
}
807+
emitAnnotateOutcome(result);
758808
process.exit(0);
759809

760810
} else if (args[0] === "archive") {
@@ -915,6 +965,7 @@ if (args[0] === "sessions") {
915965
mode: "annotate-last",
916966
sharingEnabled,
917967
shareBaseUrl,
968+
gate: gateFlag,
918969
htmlContent: planHtmlContent,
919970
onReady: async (url, isRemote, port) => {
920971
handleAnnotateServerReady(url, isRemote, port);
@@ -939,11 +990,7 @@ if (args[0] === "sessions") {
939990
await Bun.sleep(1500);
940991
server.stop();
941992

942-
if (result.exit) {
943-
console.log("Annotation session closed without feedback.");
944-
} else {
945-
console.log(result.feedback || "No feedback provided.");
946-
}
993+
emitAnnotateOutcome(result);
947994
process.exit(0);
948995

949996
} else if (args[0] === "improve-context") {

apps/marketing/src/content/docs/commands/annotate-last.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ The annotation UI in `annotate-last` mode works the same as `/plannotator-annota
7373
- Completion screen says "annotations on the message"
7474
- Feedback export is titled "Message Feedback" instead of "Plan Feedback"
7575

76+
## Flags
77+
78+
`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.
79+
80+
The common use case for `--gate` on annotate-last is a turn-by-turn review gate wired to a Stop hook:
81+
82+
```bash
83+
plannotator annotate-last --gate
84+
```
85+
86+
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/).
87+
7688
## Server API
7789

7890
The annotate-last mode reuses the same annotate server endpoints. See the [annotate docs](/docs/commands/annotate/#server-api).

0 commit comments

Comments
 (0)