Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4b2a37d
chore(annotate): start #570 — approve/annotate/dismiss flow
backnotprop Apr 23, 2026
a053e0d
feat(shared): add parseAnnotateArgs utility + tests
backnotprop Apr 23, 2026
f287c94
feat(server): gate mode and /api/approve for annotate (bun + pi parity)
backnotprop Apr 23, 2026
384e5d8
feat(editor): three-button annotate header when gate is enabled
backnotprop Apr 23, 2026
94b0144
feat(hook): --gate and --json flags for annotate / annotate-last
backnotprop Apr 23, 2026
87e651a
feat(opencode): --gate passthrough for annotate / annotate-last
backnotprop Apr 23, 2026
8d17c47
feat(pi): --gate passthrough for annotate / annotate-last
backnotprop Apr 23, 2026
47a9d24
docs(annotate): document --gate, --json, and hook recipes (#570)
backnotprop Apr 23, 2026
61193ea
fix(annotate): route Cmd/Enter to approve in gate-mode + clarify JSON…
backnotprop Apr 23, 2026
ba49e86
refactor(editor): consolidate gate-mode Approve into existing plan Ap…
backnotprop Apr 23, 2026
b8821ad
fix(hook): forward $ARGUMENTS from /plannotator-last template
backnotprop Apr 23, 2026
a8b4458
fix(hook): teach annotate templates to handle --json decisions
backnotprop Apr 23, 2026
cf87eb8
fix(opencode): forward raw args string to /plannotator-last handler
backnotprop Apr 23, 2026
a834238
fix(shared): preserve consecutive whitespace in annotate paths
backnotprop Apr 23, 2026
eed859e
fix(pi): expose exit? on PlannotatorAnnotationResult type
backnotprop Apr 23, 2026
223c671
fix(harnesses): port annotate template prose to Copilot + Gemini
backnotprop Apr 23, 2026
01b87c3
refactor(shared): consolidate @ reference handling in at-reference.ts
backnotprop Apr 23, 2026
42ef6ea
feat(annotate): literal-@ fallback in every harness
backnotprop Apr 23, 2026
127ce81
docs(guides): add Annotate Gates and JSON Responses
backnotprop Apr 23, 2026
9db8259
docs(guides): add JSON schema and example outputs
backnotprop Apr 23, 2026
ab54dba
fix(shared): strip wrapping quotes in @-reference handling
backnotprop Apr 23, 2026
9f13f25
docs+templates: distinguish approved from dismissed in agent prompts
backnotprop Apr 23, 2026
a155c6c
docs(agents): update Annotate Server API table with /api/approve + gate
backnotprop Apr 23, 2026
acb8053
feat(annotate): --gate plaintext approve emits "The user approved."
backnotprop Apr 23, 2026
97df41d
feat(annotate): add --silent-approve flag for naive hook compatibility
backnotprop Apr 23, 2026
a465af3
fix(hook): list --gate, --json, --silent-approve in usage and --help
backnotprop Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 5 additions & 1 deletion apps/copilot/commands/plannotator-annotate.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ allowed-tools: shell(plannotator:*)

## Your task

Address the annotation feedback above. The user has reviewed the markdown file 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. 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.
Comment thread
backnotprop marked this conversation as resolved.
8 changes: 6 additions & 2 deletions apps/copilot/commands/plannotator-last.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

This instruction treats empty stdout as definitely dismissed, but with --silent-approve enabled plaintext approval is also empty stdout (indistinguishable from Close). The guidance should reflect that empty output may mean either dismissed or silently approved, and recommend a neutral acknowledgement/stop behavior accordingly.

Suggested change
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.

Copilot uses AI. Check for mistakes.
6 changes: 5 additions & 1 deletion apps/gemini/commands/plannotator-annotate.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ prompt = """

## Your task

Address the annotation feedback above. The user has reviewed the markdown file 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. 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.
Comment thread
backnotprop marked this conversation as resolved.
"""
6 changes: 5 additions & 1 deletion apps/hook/commands/plannotator-annotate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

This instruction treats empty stdout as definitely dismissed, but with --silent-approve enabled plaintext approval is also empty stdout (indistinguishable from Close). The guidance should reflect that empty output may mean either dismissed or silently approved, and recommend a neutral acknowledgement/stop behavior accordingly.

Suggested change
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.

Copilot uses AI. Check for mistakes.
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.
8 changes: 6 additions & 2 deletions apps/hook/commands/plannotator-last.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ disable-model-invocation: true

## Message Annotations

!`plannotator annotate-last`
!`plannotator annotate-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.
Comment thread
backnotprop marked this conversation as resolved.
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 changes: 1 addition & 1 deletion apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function formatTopLevelHelp(): string {
" plannotator --help",
" plannotator [--browser <name>]",
" plannotator review [PR_URL]",
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina]",
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json] [--silent-approve]",
" plannotator last",
" plannotator archive",
" plannotator sessions",
Expand Down
173 changes: 110 additions & 63 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The PR description’s stdout matrix says --gate (without --json) makes both Approve and Close emit empty stdout, but emitAnnotateOutcome prints the plaintext marker on approval unless --silent-approve is set. Please align the PR description/matrix with the implemented behavior, or adjust this branch to match the described contract.

Copilot uses AI. Check for mistakes.
}
if (result.feedback) console.log(result.feedback);
}

if (isTopLevelHelpInvocation(args)) {
console.log(formatTopLevelHelp());
process.exit(0);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}`);
Expand All @@ -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}`);
}
}
}

Expand All @@ -590,6 +646,7 @@ if (args[0] === "sessions") {
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
gate: gateFlag,
htmlContent: planHtmlContent,
onReady: async (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -724,6 +777,7 @@ if (args[0] === "sessions") {
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
gate: gateFlag,
htmlContent: planHtmlContent,
onReady: async (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
Expand All @@ -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") {
Expand Down Expand Up @@ -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);
Expand All @@ -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") {
Expand Down
12 changes: 12 additions & 0 deletions apps/marketing/src/content/docs/commands/annotate-last.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `--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:

```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).
Expand Down
Loading