Skip to content

feat(annotate): approve/annotate/dismiss flow (#570)#606

Draft
backnotprop wants to merge 13 commits intomainfrom
feat/annotate-approve
Draft

feat(annotate): approve/annotate/dismiss flow (#570)#606
backnotprop wants to merge 13 commits intomainfrom
feat/annotate-approve

Conversation

@backnotprop
Copy link
Copy Markdown
Owner

@backnotprop backnotprop commented Apr 23, 2026

Resolves #570.

Design

Adds a --gate flag to plannotator annotate and plannotator annotate-last that enables a three-way review UX (Approve / Annotate / Close), plus an orthogonal --json flag for structured hook-friendly output. Ships with full parity across Claude Code, OpenCode, and Pi.

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":"..."}

Key property: in --gate mode without --json, Approve and Close are both empty stdout, so naive PostToolUse and Stop hooks work out of the box. Only Annotate blocks. Add --json only when you need explicit approved-vs-dismissed telemetry.

Parity across harnesses

Harness How --gate is wired
Claude Code Binary CLI flag. Stdout contract per matrix. Works with PostToolUse and Stop hooks.
OpenCode Plugin's slash-command handler parses --gate from args, passes gate: true to startAnnotateServer. Decision object flows through plugin's injection path: approved/dismissed skip session prompt injection, annotated injects feedback.
Pi Same as OpenCode. Event handler parses arg, passes through to server.

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)

  • Drop `"Annotation session closed without feedback."` stdout line. Close truly emits empty.
  • Slash-command template rewrite (Claude Code plannotator-annotate.md + plannotator-last.md) to handle empty-stdout gracefully.

What ships (behind --gate)

  • New /api/approve endpoint in packages/server/annotate.ts.
  • New approved field in annotate decision object.
  • Approve button in 3-button annotate UI (packages/editor/App.tsx).
  • gate prop passed through /api/plan to client.

What ships (behind --json)

  • Structured JSON stdout mode: {"decision":"approved"|"annotated"|"dismissed","feedback":"..."}.
  • Works with or without --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)

  • User-level setting to always show the gate UI without needing --gate per invocation.

For provenance purposes, this PR was AI assisted.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(annotate): approve/reject flow for spec-driven development frameworks

1 participant