|
| 1 | +# Plannotator |
| 2 | + |
| 3 | +A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. |
| 4 | + |
| 5 | +## Project Structure |
| 6 | + |
| 7 | +``` |
| 8 | +plannotator/ |
| 9 | +├── apps/ |
| 10 | +│ ├── hook/ # Claude Code plugin |
| 11 | +│ │ ├── .claude-plugin/plugin.json |
| 12 | +│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) |
| 13 | +│ │ ├── hooks/hooks.json # PermissionRequest hook config |
| 14 | +│ │ ├── server/index.ts # Entry point (plan + review + annotate subcommands) |
| 15 | +│ │ └── dist/ # Built single-file apps (index.html, review.html) |
| 16 | +│ ├── opencode-plugin/ # OpenCode plugin |
| 17 | +│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) |
| 18 | +│ │ ├── index.ts # Plugin entry with submit_plan tool + review/annotate event handlers |
| 19 | +│ │ ├── plannotator.html # Built plan review app |
| 20 | +│ │ └── review-editor.html # Built code review app |
| 21 | +│ ├── marketing/ # Marketing site, docs, and blog (plannotator.ai) |
| 22 | +│ │ └── astro.config.mjs # Astro 5 static site with content collections |
| 23 | +│ ├── paste-service/ # Paste service for short URL sharing |
| 24 | +│ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors) |
| 25 | +│ │ ├── stores/ # Storage backends (fs, kv, s3) |
| 26 | +│ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts) |
| 27 | +│ ├── review/ # Standalone review server (for development) |
| 28 | +│ │ ├── index.html |
| 29 | +│ │ ├── index.tsx |
| 30 | +│ │ └── vite.config.ts |
| 31 | +│ └── vscode-extension/ # VS Code extension — opens plans in editor tabs |
| 32 | +│ ├── bin/ # Router scripts (open-in-vscode, xdg-open) |
| 33 | +│ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts |
| 34 | +│ └── package.json # Extension manifest (publisher: backnotprop) |
| 35 | +├── packages/ |
| 36 | +│ ├── server/ # Shared server implementation |
| 37 | +│ │ ├── index.ts # startPlannotatorServer(), handleServerReady() |
| 38 | +│ │ ├── review.ts # startReviewServer(), handleReviewServerReady() |
| 39 | +│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() |
| 40 | +│ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.) |
| 41 | +│ │ ├── share-url.ts # Server-side share URL generation for remote sessions |
| 42 | +│ │ ├── remote.ts # isRemoteSession(), getServerPort() |
| 43 | +│ │ ├── browser.ts # openBrowser() |
| 44 | +│ │ ├── draft.ts # Annotation draft persistence (~/.plannotator/drafts/) |
| 45 | +│ │ ├── integrations.ts # Obsidian, Bear integrations |
| 46 | +│ │ ├── ide.ts # VS Code diff integration (openEditorDiff) |
| 47 | +│ │ ├── editor-annotations.ts # VS Code editor annotation endpoints |
| 48 | +│ │ └── project.ts # Project name detection for tags |
| 49 | +│ ├── ui/ # Shared React components |
| 50 | +│ │ ├── components/ # Viewer, Toolbar, Settings, etc. |
| 51 | +│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views |
| 52 | +│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser |
| 53 | +│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts |
| 54 | +│ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts |
| 55 | +│ │ └── types.ts |
| 56 | +│ ├── shared/ # Cross-package types (EditorAnnotation) |
| 57 | +│ ├── editor/ # Plan review App.tsx |
| 58 | +│ └── review-editor/ # Code review UI |
| 59 | +│ ├── App.tsx # Main review app |
| 60 | +│ ├── components/ # DiffViewer, FileTree, ReviewPanel |
| 61 | +│ ├── demoData.ts # Demo diff for standalone mode |
| 62 | +│ └── index.css # Review-specific styles |
| 63 | +├── .claude-plugin/marketplace.json # For marketplace install |
| 64 | +└── legacy/ # Old pre-monorepo code (reference only) |
| 65 | +``` |
| 66 | + |
| 67 | +## Installation |
| 68 | + |
| 69 | +**Via plugin marketplace** (when repo is public): |
| 70 | + |
| 71 | +``` |
| 72 | +/plugin marketplace add backnotprop/plannotator |
| 73 | +``` |
| 74 | + |
| 75 | +**Local testing:** |
| 76 | + |
| 77 | +```bash |
| 78 | +claude --plugin-dir ./apps/hook |
| 79 | +``` |
| 80 | + |
| 81 | +## Environment Variables |
| 82 | + |
| 83 | +| Variable | Description | |
| 84 | +|----------|-------------| |
| 85 | +| `PLANNOTATOR_REMOTE` | Set to `1` or `true` for remote mode (devcontainer, SSH). Uses fixed port and skips browser open. | |
| 86 | +| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | |
| 87 | +| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | |
| 88 | +| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | |
| 89 | +| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | |
| 90 | + |
| 91 | +**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected. Prefer `PLANNOTATOR_REMOTE=1` for explicit control. |
| 92 | + |
| 93 | +**Devcontainer/SSH usage:** |
| 94 | +```bash |
| 95 | +export PLANNOTATOR_REMOTE=1 |
| 96 | +export PLANNOTATOR_PORT=9999 |
| 97 | +``` |
| 98 | + |
| 99 | +## Plan Review Flow |
| 100 | + |
| 101 | +``` |
| 102 | +Claude calls ExitPlanMode |
| 103 | + ↓ |
| 104 | +PermissionRequest hook fires |
| 105 | + ↓ |
| 106 | +Bun server reads plan from stdin JSON (tool_input.plan) |
| 107 | + ↓ |
| 108 | +Server starts on random port, opens browser |
| 109 | + ↓ |
| 110 | +User reviews plan, optionally adds annotations |
| 111 | + ↓ |
| 112 | +Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}} |
| 113 | +Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}} |
| 114 | +``` |
| 115 | + |
| 116 | +## Code Review Flow |
| 117 | + |
| 118 | +``` |
| 119 | +User runs /plannotator-review command |
| 120 | + ↓ |
| 121 | +Claude Code: plannotator review subcommand runs |
| 122 | +OpenCode: event handler intercepts command |
| 123 | + ↓ |
| 124 | +git diff captures unstaged changes |
| 125 | + ↓ |
| 126 | +Review server starts, opens browser with diff viewer |
| 127 | + ↓ |
| 128 | +User annotates code, provides feedback |
| 129 | + ↓ |
| 130 | +Send Feedback → feedback sent to agent session |
| 131 | +Approve → "LGTM" sent to agent session |
| 132 | +``` |
| 133 | + |
| 134 | +## Annotate Flow |
| 135 | + |
| 136 | +``` |
| 137 | +User runs /plannotator-annotate <file.md> command |
| 138 | + ↓ |
| 139 | +Claude Code: plannotator annotate subcommand runs |
| 140 | +OpenCode: event handler intercepts command |
| 141 | + ↓ |
| 142 | +Markdown file read from disk |
| 143 | + ↓ |
| 144 | +Annotate server starts (reuses plan editor HTML with mode:"annotate") |
| 145 | + ↓ |
| 146 | +User annotates markdown, provides feedback |
| 147 | + ↓ |
| 148 | +Send Annotations → feedback sent to agent session |
| 149 | +``` |
| 150 | + |
| 151 | +## Server API |
| 152 | + |
| 153 | +### Plan Server (`packages/server/index.ts`) |
| 154 | + |
| 155 | +| Endpoint | Method | Purpose | |
| 156 | +| --------------------- | ------ | ------------------------------------------ | |
| 157 | +| `/api/plan` | GET | Returns `{ plan, origin, previousPlan, versionInfo }` | |
| 158 | +| `/api/plan/version` | GET | Fetch specific version (`?v=N`) | |
| 159 | +| `/api/plan/versions` | GET | List all versions of current plan | |
| 160 | +| `/api/plan/history` | GET | List all plans in current project | |
| 161 | +| `/api/approve` | POST | Approve plan (body: planSave, agentSwitch, obsidian, bear, feedback) | |
| 162 | +| `/api/deny` | POST | Deny plan (body: feedback, planSave) | |
| 163 | +| `/api/image` | GET | Serve image by path query param | |
| 164 | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | |
| 165 | +| `/api/obsidian/vaults`| GET | Detect available Obsidian vaults | |
| 166 | +| `/api/reference/obsidian/files` | GET | List vault markdown files as nested tree (`?vaultPath=<path>`) | |
| 167 | +| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=<path>&path=<file>`) | |
| 168 | +| `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) | |
| 169 | +| `/api/doc` | GET | Serve linked .md/.mdx file (`?path=<path>`) | |
| 170 | +| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | |
| 171 | +| `/api/editor-annotations` | GET | List editor annotations (VS Code only) | |
| 172 | +| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) | |
| 173 | + |
| 174 | +### Review Server (`packages/server/review.ts`) |
| 175 | + |
| 176 | +| Endpoint | Method | Purpose | |
| 177 | +| --------------------- | ------ | ------------------------------------------ | |
| 178 | +| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` | |
| 179 | +| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context | |
| 180 | +| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) | |
| 181 | +| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) | |
| 182 | +| `/api/image` | GET | Serve image by path query param | |
| 183 | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | |
| 184 | +| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | |
| 185 | +| `/api/editor-annotations` | GET | List editor annotations (VS Code only) | |
| 186 | +| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) | |
| 187 | + |
| 188 | +### Annotate Server (`packages/server/annotate.ts`) |
| 189 | + |
| 190 | +| Endpoint | Method | Purpose | |
| 191 | +| --------------------- | ------ | ------------------------------------------ | |
| 192 | +| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath }` | |
| 193 | +| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) | |
| 194 | +| `/api/image` | GET | Serve image by path query param | |
| 195 | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | |
| 196 | +| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | |
| 197 | + |
| 198 | +All servers use random ports locally or fixed port (`19432`) in remote mode. |
| 199 | + |
| 200 | +### Paste Service (`apps/paste-service/`) |
| 201 | + |
| 202 | +| Endpoint | Method | Purpose | |
| 203 | +| --------------------- | ------ | ------------------------------------------ | |
| 204 | +| `/api/paste` | POST | Store compressed plan data, returns `{ id }` | |
| 205 | +| `/api/paste/:id` | GET | Retrieve stored compressed data | |
| 206 | + |
| 207 | +Runs as a separate service on port `19433` (self-hosted) or as a Cloudflare Worker (hosted). |
| 208 | + |
| 209 | +## Plan Version History |
| 210 | + |
| 211 | +Every plan is automatically saved to `~/.plannotator/history/{project}/{slug}/` on arrival, before the user sees the UI. Versions are numbered sequentially (`001.md`, `002.md`, etc.). The slug is derived from the plan's first `# Heading` + today's date via `generateSlug()`, scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version). |
| 212 | + |
| 213 | +This powers the version history API (`/api/plan/version`, `/api/plan/versions`, `/api/plan/history`) and the plan diff system. |
| 214 | + |
| 215 | +History saves independently of the `planSave` user setting (which controls decision snapshots in `~/.plannotator/plans/`). Storage functions live in `packages/server/storage.ts`, with Node-compatible duplicates in `apps/pi-extension/server.ts`. Slug format: `{sanitized-heading}-YYYY-MM-DD` (heading first for readability). |
| 216 | + |
| 217 | +## Plan Diff |
| 218 | + |
| 219 | +When a user denies a plan and Claude resubmits, the UI shows what changed between versions. A `+N/-M` badge appears below the document card; clicking it toggles between normal view and diff view. |
| 220 | + |
| 221 | +**Diff engine** (`packages/ui/utils/planDiffEngine.ts`): Uses the `diff` npm package (`diffLines()`) to compute line-level diffs. Groups consecutive remove+add into "modified" blocks. Returns `PlanDiffBlock[]` and `PlanDiffStats`. |
| 222 | + |
| 223 | +**Two view modes** (toggle via `PlanDiffModeSwitcher`): |
| 224 | +- **Rendered** (`PlanCleanDiffView`): Color-coded left borders — green (added), red (removed/strikethrough), yellow (modified) |
| 225 | +- **Raw** (`PlanRawDiffView`): Monospace `+/-` lines, git-style |
| 226 | + |
| 227 | +**State** (`packages/ui/hooks/usePlanDiff.ts`): Manages base version selection, diff computation, and version fetching. The server sends `previousPlan` with the initial `/api/plan` response; the hook auto-diffs against it. Users can select any prior version from the sidebar Version Browser. |
| 228 | + |
| 229 | +**Sidebar** (`packages/ui/hooks/useSidebar.ts`): Shared left sidebar with two tabs — Table of Contents and Version Browser. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). |
| 230 | + |
| 231 | +## Data Types |
| 232 | + |
| 233 | +**Location:** `packages/ui/types.ts` |
| 234 | + |
| 235 | +```typescript |
| 236 | +enum AnnotationType { |
| 237 | + DELETION = "DELETION", |
| 238 | + INSERTION = "INSERTION", |
| 239 | + REPLACEMENT = "REPLACEMENT", |
| 240 | + COMMENT = "COMMENT", |
| 241 | + GLOBAL_COMMENT = "GLOBAL_COMMENT", |
| 242 | +} |
| 243 | + |
| 244 | +interface ImageAttachment { |
| 245 | + path: string; // temp file path |
| 246 | + name: string; // human-readable label (e.g., "login-mockup") |
| 247 | +} |
| 248 | + |
| 249 | +interface Annotation { |
| 250 | + id: string; |
| 251 | + blockId: string; |
| 252 | + startOffset: number; |
| 253 | + endOffset: number; |
| 254 | + type: AnnotationType; |
| 255 | + text?: string; // For comment/replacement/insertion |
| 256 | + originalText: string; // The selected text |
| 257 | + createdA: number; // Timestamp |
| 258 | + author?: string; // Tater identity |
| 259 | + images?: ImageAttachment[]; // Attached images with names |
| 260 | + startMeta?: { parentTagName; parentIndex; textOffset }; |
| 261 | + endMeta?: { parentTagName; parentIndex; textOffset }; |
| 262 | +} |
| 263 | + |
| 264 | +interface Block { |
| 265 | + id: string; |
| 266 | + type: "paragraph" | "heading" | "blockquote" | "list-item" | "code" | "hr"; |
| 267 | + content: string; |
| 268 | + level?: number; // For headings (1-6) |
| 269 | + language?: string; // For code blocks |
| 270 | + order: number; |
| 271 | + startLine: number; |
| 272 | +} |
| 273 | +``` |
| 274 | + |
| 275 | +## Markdown Parser |
| 276 | + |
| 277 | +**Location:** `packages/ui/utils/parser.ts` |
| 278 | + |
| 279 | +`parseMarkdownToBlocks(markdown)` splits markdown into Block objects. Handles: |
| 280 | + |
| 281 | +- Headings (`#`, `##`, etc.) |
| 282 | +- Code blocks (``` with language extraction) |
| 283 | +- List items (`-`, `*`, `1.`) |
| 284 | +- Blockquotes (`>`) |
| 285 | +- Horizontal rules (`---`) |
| 286 | +- Paragraphs (default) |
| 287 | + |
| 288 | +`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. |
| 289 | + |
| 290 | +## Annotation System |
| 291 | + |
| 292 | +**Selection mode:** User selects text → toolbar appears → choose annotation type |
| 293 | +**Redline mode:** User selects text → auto-creates DELETION annotation |
| 294 | + |
| 295 | +Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>` wrapping (web-highlighter can't select inside `<pre>`). |
| 296 | + |
| 297 | +## URL Sharing |
| 298 | + |
| 299 | +**Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts` |
| 300 | + |
| 301 | +Shares full plan + annotations via URL hash using deflate compression. For large plans, short URLs are created via the paste service (user must explicitly confirm). |
| 302 | + |
| 303 | +**Payload format:** |
| 304 | + |
| 305 | +```typescript |
| 306 | +// Image in shareable format: plain string (old) or [path, name] tuple (new) |
| 307 | +type ShareableImage = string | [string, string]; |
| 308 | + |
| 309 | +interface SharePayload { |
| 310 | + p: string; // Plan markdown |
| 311 | + a: ShareableAnnotation[]; // Compact annotations |
| 312 | + g?: ShareableImage[]; // Global attachments |
| 313 | +} |
| 314 | + |
| 315 | +type ShareableAnnotation = |
| 316 | + | ["D", string, string | null, ShareableImage[]?] // [type, original, author, images?] |
| 317 | + | ["R", string, string, string | null, ShareableImage[]?] // [type, original, replacement, author, images?] |
| 318 | + | ["C", string, string, string | null, ShareableImage[]?] // [type, original, comment, author, images?] |
| 319 | + | ["I", string, string, string | null, ShareableImage[]?] // [type, context, newText, author, images?] |
| 320 | + | ["G", string, string | null, ShareableImage[]?]; // [type, comment, author, images?] |
| 321 | +``` |
| 322 | + |
| 323 | +**Compression pipeline:** |
| 324 | + |
| 325 | +1. `JSON.stringify(payload)` |
| 326 | +2. `CompressionStream('deflate-raw')` |
| 327 | +3. Base64 encode |
| 328 | +4. URL-safe: replace `+/=` with `-_` |
| 329 | + |
| 330 | +**On load from shared URL:** |
| 331 | + |
| 332 | +1. Parse hash, decompress, restore annotations |
| 333 | +2. Find text positions in rendered DOM via text search |
| 334 | +3. Apply `<mark>` highlights |
| 335 | +4. Clear hash from URL (prevents re-parse on refresh) |
| 336 | + |
| 337 | +## Settings Persistence |
| 338 | + |
| 339 | +**Location:** `packages/ui/utils/storage.ts`, `planSave.ts`, `agentSwitch.ts` |
| 340 | + |
| 341 | +Uses cookies (not localStorage) because each hook invocation runs on a random port. Settings include identity, plan saving (enabled/custom path), and agent switching (OpenCode only). |
| 342 | + |
| 343 | +## Syntax Highlighting |
| 344 | + |
| 345 | +Code blocks use bundled `highlight.js`. Language is extracted from fence (```rust) and applied as `language-{lang}`class. Each block highlighted individually via`hljs.highlightElement()`. |
| 346 | + |
| 347 | +## Requirements |
| 348 | + |
| 349 | +- Bun runtime |
| 350 | +- Claude Code with plugin/hooks support, or OpenCode |
| 351 | +- Cross-platform: macOS (`open`), Linux (`xdg-open`), Windows (`start`) |
| 352 | + |
| 353 | +## Development |
| 354 | + |
| 355 | +```bash |
| 356 | +bun install |
| 357 | + |
| 358 | +# Run any app |
| 359 | +bun run dev:hook # Hook server (plan review) |
| 360 | +bun run dev:review # Review editor (code review) |
| 361 | +bun run dev:portal # Portal editor |
| 362 | +bun run dev:marketing # Marketing site |
| 363 | +bun run dev:vscode # VS Code extension (watch mode) |
| 364 | +``` |
| 365 | + |
| 366 | +## Build |
| 367 | + |
| 368 | +```bash |
| 369 | +bun run build:hook # Single-file HTML for hook server |
| 370 | +bun run build:review # Code review editor |
| 371 | +bun run build:opencode # OpenCode plugin (copies HTML from hook + review) |
| 372 | +bun run build:portal # Static build for share.plannotator.ai |
| 373 | +bun run build:marketing # Static build for plannotator.ai |
| 374 | +bun run build:vscode # VS Code extension bundle |
| 375 | +bun run package:vscode # Package .vsix for marketplace |
| 376 | +bun run build # Build hook + opencode (main targets) |
| 377 | +``` |
| 378 | + |
| 379 | +**Important:** The OpenCode plugin copies pre-built HTML from `apps/hook/dist/` and `apps/review/dist/`. When making UI changes (in `packages/ui/`, `packages/editor/`, or `packages/review-editor/`), you must rebuild the hook/review first: |
| 380 | + |
| 381 | +```bash |
| 382 | +bun run build:hook && bun run build:opencode # For UI changes |
| 383 | +``` |
| 384 | + |
| 385 | +Running only `build:opencode` will copy stale HTML files. |
| 386 | + |
| 387 | +## Marketing Site |
| 388 | + |
| 389 | +`apps/marketing/` is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in `src/content/docs/`, blog posts in `src/content/blog/`, both using Astro content collections. Tailwind CSS v4 via `@tailwindcss/vite`. Deploys to S3/CloudFront via GitHub Actions on push to main. |
| 390 | + |
| 391 | +## Test plugin locally |
| 392 | + |
| 393 | +``` |
| 394 | +claude --plugin-dir ./apps/hook |
| 395 | +``` |
0 commit comments