feat(annotate): approve/annotate/dismiss flow (#570)#606
Conversation
|
This is huge improvment and can't wait to start using it! The I did a careful pass on the current branch and the main behavior looks good from my side:
I also really appreciate the extra polish here, especially around I did hit one regression that still reproduces for me on the current branch tip:
Repro:
So it looks like Everything else I checked around the new gate flow looks solid, so this feels more like stale-branch drift than a problem with the feature itself. Happy to help if useful: i can add some targeted tests arount that path, fix restoring the OpenCode path/folder behavior, or both :) |
|
Quick amendment to the matrix above.
Added Approve and Close both emit empty; only Send Annotations blocks with feedback. That's the silence-is-permission shape originally agreed on. Updated matrix:
Sorry for the late amendment. Landing in the next release. |
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in “review gate” flow to annotation mode so users (and hook-driven agent workflows) can explicitly Approve / Annotate / Close, with optional structured decision output for automation across Claude Code, OpenCode, and Pi.
Changes:
- Introduces
--gate(3-way review UX) plus decision plumbing (approvedfield,/api/approve,gatepropagated via/api/plan). - Adds
--jsondecision stdout mode and--silent-approveto support “silence-is-permission” hook patterns. - Centralizes harness parsing and path normalization via new shared helpers (
parseAnnotateArgs,stripAtPrefix/resolveAtReference) and updates docs/templates.
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shared/resolve-file.ts | Exports stripWrappingQuotes for reuse by new argument/path helpers. |
| packages/shared/package.json | Exposes new shared entrypoints (annotate-args, at-reference). |
| packages/shared/at-reference.ts | Adds consistent @path normalization + literal fallback logic. |
| packages/shared/at-reference.test.ts | Unit tests for @-reference handling (including quote stripping). |
| packages/shared/annotate-args.ts | Adds slash-command-style arg parsing for --gate/--json/--silent-approve while preserving internal whitespace. |
| packages/shared/annotate-args.test.ts | Extensive tests pinning parsing behavior (whitespace, quotes, @ behavior, flags). |
| packages/server/annotate.ts | Adds gate option, propagates it to UI via /api/plan, and adds /api/approve. |
| packages/editor/App.tsx | Adds gate-mode approve behavior/UX, state routing for exit warnings, and reuses /api/approve. |
| apps/opencode-plugin/index.ts | Passes raw command tail through so --gate/--json can be parsed downstream. |
| apps/opencode-plugin/commands.ts | Parses annotate args, implements @ literal fallback semantics, and treats approved like exit (no injection). |
| apps/pi-extension/vendor.sh | Vendors new shared helper modules into the extension build. |
| apps/pi-extension/server/serverAnnotate.ts | Adds gate option and approved decision plumbing in Pi’s server wrapper. |
| apps/pi-extension/plannotator-events.ts | Extends Pi event payload/result types to include gate and approved. |
| apps/pi-extension/plannotator-browser.ts | Threads gate through to startAnnotateServer and returns approved. |
| apps/pi-extension/index.ts | Parses args for slash commands, resolves @ references with fallback, and handles approved result. |
| apps/hook/server/index.ts | Implements stdout decision matrix (--gate/--json/--silent-approve), adds /api/approve flow, and improves @ handling. |
| apps/hook/commands/plannotator-annotate.md | Updates command template to pass $ARGUMENTS and documents decision outcomes. |
| apps/hook/commands/plannotator-last.md | Same as above for annotate-last template. |
| apps/copilot/commands/plannotator-annotate.md | Updates Copilot command template to pass $ARGUMENTS and documents outcomes. |
| apps/copilot/commands/plannotator-last.md | Same as above for copilot-last template. |
| apps/gemini/commands/plannotator-annotate.toml | Updates Gemini command prompt to document decision outcomes. |
| apps/marketing/src/content/docs/commands/annotate.md | Documents new flags, stdout matrix, and /api/approve endpoint. |
| apps/marketing/src/content/docs/commands/annotate-last.md | Documents new flags for annotate-last. |
| apps/marketing/src/content/docs/guides/hook-integration.md | Adds hook recipes for gating workflows, including --silent-approve patterns. |
| apps/marketing/src/content/docs/guides/annotate-gates-and-json-responses.md | Adds detailed guide for gate + JSON decision semantics. |
| AGENTS.md | Updates server API contract docs to include gate and /api/approve. |
| bun.lock | Bumps package versions for the release. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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. |
There was a problem hiding this comment.
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.
| 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. |
| if (result.exit) return; // empty stdout on close | ||
| if (result.approved) { | ||
| if (!silentApproveFlag) console.log(APPROVED_PLAINTEXT_MARKER); | ||
| return; |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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.
| 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. |
Draft PR placeholder. Design in description. For provenance purposes, this commit was AI assisted.
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.
- 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.
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.
- 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.
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.
- 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.
- 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.
… recipe 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.
…prove
Three parallel structures merged into their existing counterparts:
1. Remove the second <ApproveButton> 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.
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.
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.
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.
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 53b87b2 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
3718e98 to
97df41d
Compare
For provenance purposes, this commit was AI assisted.
|
Post-merge follow-up: After reviewing the Claude Code and Codex hook documentation, we found that neither system treats plain text stdout as a block signal. Both require
|
…protocol
Replaces `--silent-approve` with `--hook`, which emits hook-native JSON
that works directly with Claude Code and Codex PostToolUse/Stop hooks.
- Approve/Close: empty stdout (hook passes)
- Annotate: `{"decision":"block","reason":"<feedback>"}` (hook blocks)
- `--hook` implies `--gate` (three-button UX), applied in the parser
so all harnesses (binary, OpenCode, Pi) get it automatically
- Rewrites hook integration guide to be agent-agnostic
Follow-up to #606. Refs #570.
For provenance purposes, this commit was AI assisted.
…protocol
Replaces `--silent-approve` with `--hook`, which emits hook-native JSON
that works directly with Claude Code and Codex PostToolUse/Stop hooks.
- Approve/Close: empty stdout (hook passes)
- Annotate: `{"decision":"block","reason":"<feedback>"}` (hook blocks)
- `--hook` implies `--gate` (three-button UX), applied in the parser
so all harnesses (binary, OpenCode, Pi) get it automatically
- Rewrites hook integration guide to be agent-agnostic
Follow-up to backnotprop#606. Refs backnotprop#570.
For provenance purposes, this commit was AI assisted.
Resolves #570.
Design
Adds a
--gateflag toplannotator annotateandplannotator annotate-lastthat enables a three-way review UX (Approve / Annotate / Close), plus an orthogonal--jsonflag for structured hook-friendly output. Ships with full parity across Claude Code, OpenCode, and Pi.Stdout contract
Key property: in
--gatemode without--json, Approve and Close are both empty stdout, so naive PostToolUse and Stop hooks work out of the box. Only Annotate blocks. Add--jsononly when you need explicit approved-vs-dismissed telemetry.Parity across harnesses
--gateis wired--gatefrom args, passesgate: truetostartAnnotateServer. Decision object flows through plugin's injection path: approved/dismissed skip session prompt injection, annotated injects feedback.Shipped slash commands stay 2-button by default in all three harnesses. Users opt in per invocation:
/plannotator-annotate spec.md --gate.What ships (unconditional)
plannotator-annotate.md+plannotator-last.md) to handle empty-stdout gracefully.What ships (behind
--gate)/api/approveendpoint inpackages/server/annotate.ts.approvedfield in annotate decision object.packages/editor/App.tsx).gateprop passed through/api/planto client.What ships (behind
--json){"decision":"approved"|"annotated"|"dismissed","feedback":"..."}.--gate.Recipes (ships as docs)
PostToolUse spec gate (Claude Code). For spec-driven frameworks (spec-kit, kiro, openspec):
```bash
plannotator annotate --gate
```
Stop-hook turn gate (Claude Code). For turn-by-turn review:
```bash
plannotator annotate-last --gate
```
Future (not in this PR)
--gateper invocation.For provenance purposes, this PR was AI assisted.