diff --git a/.gitignore b/.gitignore index a547bf3..9ae8ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ dist dist-ssr *.local +# npm pack output +*.tgz + +# OpenCode plugin build artifact (generated from hook dist) +apps/opencode-plugin/plannotator.html + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/CLAUDE.md b/CLAUDE.md index e38dd7e..56698aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,11 @@ plannotator/ │ ├── hooks/hooks.json # PermissionRequest hook config │ ├── server/index.ts # Bun server (reads stdin, serves UI) │ └── dist/index.html # Built single-file app +├── apps/opencode-plugin/ # OpenCode plugin +│ └── index.ts # Plugin with submit_plan tool +├── apps/mcp/ # General MCP Server +│ ├── index.ts # MCP server with submit_plan tool +│ └── README.md # Integration documentation ├── packages/ │ ├── ui/ # Shared React components │ │ ├── components/ # Viewer, Toolbar, Settings, etc. @@ -75,6 +80,7 @@ enum AnnotationType { INSERTION = "INSERTION", REPLACEMENT = "REPLACEMENT", COMMENT = "COMMENT", + GLOBAL_COMMENT = "GLOBAL_COMMENT", } interface Annotation { @@ -142,7 +148,8 @@ type ShareableAnnotation = | ["D", string, string | null] // [type, original, author] | ["R", string, string, string | null] // [type, original, replacement, author] | ["C", string, string, string | null] // [type, original, comment, author] - | ["I", string, string, string | null]; // [type, context, newText, author] + | ["I", string, string, string | null] // [type, context, newText, author] + | ["G", string, string | null]; // [type, comment, author] - global comment ``` **Compression pipeline:** diff --git a/README.md b/README.md index 341e850..ee4983f 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,38 @@

- Plannotator + Plannotator

# Plannotator -Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with Claude Code plan mode. +Interactive Plan Review for AI Coding Agents. Mark up and refine your plans using a visual UI, share for team collaboration, and seamlessly integrate with **Claude Code** and **OpenCode**. -

- Watch video -

-

- - Watch the demo - -

+ + + + + +
+

Claude Code

+ +Claude Code Demo + +

Watch Demo

+
+

OpenCode

+ +OpenCode Demo + +

Watch Demo

+
-## Install -**Install the `plannotator` command so Claude Code can use it:** +**New:** + + - We now support auto-saving approved plans to [Obsidian](https://obsidian.md/). + +## Install for Claude Code + +**Install the `plannotator` command:** **macOS / Linux / WSL:** @@ -42,14 +57,30 @@ irm https://plannotator.ai/install.ps1 | iex See [apps/hook/README.md](apps/hook/README.md) for detailed installation instructions including a `manual hook` approach. +--- + +## Install for OpenCode + +Add to your `opencode.json`: + +```json +{ + "plugin": ["@plannotator/opencode"] +} +``` + +That's it! Restart OpenCode and the `submit_plan` tool will be available. + +--- + ## How It Works -When Claude Code calls `ExitPlanMode`, this hook intercepts and: +When your AI agent finishes planning, Plannotator: -1. Opens Plannotator UI in your browser -2. Lets you annotate the plan visually -3. Approve → Claude proceeds with implementation -4. Request changes → Your annotations are sent back to Claude +1. Opens the Plannotator UI in your browser +2. Lets you annotate the plan visually (delete, insert, replace, comment) +3. **Approve** → Agent proceeds with implementation +4. **Request changes** → Your annotations are sent back as structured feedback --- diff --git a/apps/hook/.claude-plugin/plugin.json b/apps/hook/.claude-plugin/plugin.json index 69d3187..063263d 100644 --- a/apps/hook/.claude-plugin/plugin.json +++ b/apps/hook/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "plannotator", "description": "Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with plan mode hooks.", - "version": "0.1.0", + "version": "0.3.0", "author": { "name": "backnotprop" }, diff --git a/apps/hook/README.md b/apps/hook/README.md index 3059816..4ddcefa 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -23,7 +23,7 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d --- -[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) +[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) --- @@ -68,4 +68,37 @@ When Claude Code calls `ExitPlanMode`, this hook intercepts and: 1. Opens Plannotator UI in your browser 2. Lets you annotate the plan visually 3. Approve → Claude proceeds with implementation -4. Request changes → Your annotations are sent back to Claude \ No newline at end of file +4. Request changes → Your annotations are sent back to Claude + +## Obsidian Integration + +Approved plans can be automatically saved to your Obsidian vault. + +**Setup:** +1. Open Settings (gear icon) in Plannotator +2. Enable "Obsidian Integration" +3. Select your vault from the dropdown (auto-detected) or enter the path manually +4. Set folder name (default: `plannotator`) + +**What gets saved:** +- Plans saved with human-readable filenames: `Title - Jan 2, 2026 2-30pm.md` +- YAML frontmatter with `created`, `source`, and `tags` +- Tags extracted automatically from the plan title and code languages +- Backlink to `[[Plannotator Plans]]` for graph connectivity + +**Example saved file:** +```markdown +--- +created: 2026-01-02T14:30:00.000Z +source: plannotator +tags: [plan, authentication, typescript, sql] +--- + +[[Plannotator Plans]] + +# Implementation Plan: User Authentication +... +``` + +image + diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index d3d4ca3..c15d5cb 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -2,16 +2,248 @@ * Plannotator Ephemeral Server * * Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions. - * Uses random port to support multiple concurrent Claude Code sessions. + * Supports both local and SSH remote sessions. + * + * Environment variables: + * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 over SSH) * * Reads hook event from stdin, extracts plan content, serves UI, returns decision. */ import { $ } from "bun"; +import { join } from "path"; +import { mkdirSync, existsSync, statSync } from "fs"; + +// --- Obsidian Integration --- + +interface ObsidianConfig { + vaultPath: string; + folder: string; + plan: string; +} + +/** + * Extract tags from markdown content using simple heuristics + */ +function extractTags(markdown: string): string[] { + const tags = new Set(["plan"]); + + const stopWords = new Set([ + "the", "and", "for", "with", "this", "that", "from", "into", + "plan", "implementation", "overview", "phase", "step", "steps", + ]); + + // Extract from first H1 title + const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im); + if (h1Match) { + const titleWords = h1Match[1] + .toLowerCase() + .replace(/[^\w\s-]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 2 && !stopWords.has(word)); + titleWords.slice(0, 3).forEach((word) => tags.add(word)); + } + + // Extract code fence languages + const langMatches = markdown.matchAll(/```(\w+)/g); + const seenLangs = new Set(); + for (const [, lang] of langMatches) { + const normalizedLang = lang.toLowerCase(); + if ( + !seenLangs.has(normalizedLang) && + !["json", "yaml", "yml", "text", "txt", "markdown", "md"].includes(normalizedLang) + ) { + seenLangs.add(normalizedLang); + tags.add(normalizedLang); + } + } + + return Array.from(tags).slice(0, 6); +} + +/** + * Generate frontmatter for the note + */ +function generateFrontmatter(tags: string[]): string { + const now = new Date().toISOString(); + const tagList = tags.map((t) => t.toLowerCase()).join(", "); + return `--- +created: ${now} +source: plannotator +tags: [${tagList}] +---`; +} + +/** + * Extract title from markdown (first H1 heading) + */ +function extractTitle(markdown: string): string { + const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im); + if (h1Match) { + // Clean up the title for use as filename + return h1Match[1] + .trim() + .replace(/[<>:"/\\|?*]/g, '') // Remove invalid filename chars + .replace(/\s+/g, ' ') // Normalize whitespace + .slice(0, 50); // Limit length + } + return 'Plan'; +} + +/** + * Generate human-readable filename: Title - Mon D, YYYY H-MMam.md + * Example: User Authentication - Jan 2, 2026 2-30pm.md + */ +function generateFilename(markdown: string): string { + const title = extractTitle(markdown); + const now = new Date(); + + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[now.getMonth()]; + const day = now.getDate(); + const year = now.getFullYear(); + + let hours = now.getHours(); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const ampm = hours >= 12 ? 'pm' : 'am'; + hours = hours % 12 || 12; + + return `${title} - ${month} ${day}, ${year} ${hours}-${minutes}${ampm}.md`; +} + +/** + * Detect Obsidian vaults by reading Obsidian's config file + * Returns array of vault paths found on the system + */ +function detectObsidianVaults(): string[] { + try { + const home = process.env.HOME || process.env.USERPROFILE || ""; + let configPath: string; + + // Platform-specific config locations + if (process.platform === "darwin") { + configPath = join(home, "Library/Application Support/obsidian/obsidian.json"); + } else if (process.platform === "win32") { + const appData = process.env.APPDATA || join(home, "AppData/Roaming"); + configPath = join(appData, "obsidian/obsidian.json"); + } else { + // Linux + configPath = join(home, ".config/obsidian/obsidian.json"); + } + + if (!existsSync(configPath)) { + return []; + } + + const configContent = require("fs").readFileSync(configPath, "utf-8"); + const config = JSON.parse(configContent); + + if (!config.vaults || typeof config.vaults !== "object") { + return []; + } + + // Extract vault paths, filter to ones that exist + const vaults: string[] = []; + for (const vaultId of Object.keys(config.vaults)) { + const vault = config.vaults[vaultId]; + if (vault.path && existsSync(vault.path)) { + vaults.push(vault.path); + } + } + + return vaults; + } catch { + return []; + } +} + +/** + * Save plan to Obsidian vault with cross-platform path handling + * Returns { success: boolean, error?: string, path?: string } + */ +async function saveToObsidian( + config: ObsidianConfig +): Promise<{ success: boolean; error?: string; path?: string }> { + try { + const { vaultPath, folder, plan } = config; + + // Normalize path (handle ~ on Unix, forward/back slashes) + let normalizedVault = vaultPath.trim(); + + // Expand ~ to home directory (Unix/macOS) + if (normalizedVault.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + normalizedVault = join(home, normalizedVault.slice(1)); + } + + // Validate vault path exists and is a directory + if (!existsSync(normalizedVault)) { + return { success: false, error: `Vault path does not exist: ${normalizedVault}` }; + } + + const vaultStat = statSync(normalizedVault); + if (!vaultStat.isDirectory()) { + return { success: false, error: `Vault path is not a directory: ${normalizedVault}` }; + } + + // Build target folder path + const folderName = folder.trim() || "plannotator"; + const targetFolder = join(normalizedVault, folderName); + + // Create folder if it doesn't exist + mkdirSync(targetFolder, { recursive: true }); + + // Generate filename and full path + const filename = generateFilename(plan); + const filePath = join(targetFolder, filename); + + // Generate content with frontmatter and backlink + const tags = extractTags(plan); + const frontmatter = generateFrontmatter(tags); + const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; + + // Write file + await Bun.write(filePath, content); + + return { success: true, path: filePath }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return { success: false, error: message }; + } +} // Embed the built HTML at compile time import indexHtml from "../dist/index.html" with { type: "text" }; +// --- SSH Detection and Port Configuration --- + +const DEFAULT_SSH_PORT = 19432; + +function isSSHSession(): boolean { + // SSH_TTY is set when SSH allocates a pseudo-terminal + // SSH_CONNECTION contains "client_ip client_port server_ip server_port" + return !!(process.env.SSH_TTY || process.env.SSH_CONNECTION); +} + +function getServerPort(): number { + // Explicit port from environment takes precedence + const envPort = process.env.PLANNOTATOR_PORT; + if (envPort) { + const parsed = parseInt(envPort, 10); + if (!isNaN(parsed) && parsed > 0 && parsed < 65536) { + return parsed; + } + console.error(`Warning: Invalid PLANNOTATOR_PORT "${envPort}", using default`); + } + + // Over SSH, use fixed port for port forwarding; locally use random + return isSSHSession() ? DEFAULT_SSH_PORT : 0; +} + +const isRemote = isSSHSession(); +const configuredPort = getServerPort(); + // Read hook event from stdin const eventJson = await Bun.stdin.text(); @@ -35,50 +267,122 @@ const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>( (resolve) => { resolveDecision = resolve; } ); -const server = Bun.serve({ - port: 0, // Random available port - critical for multi-instance support +// --- Server with port conflict handling --- - async fetch(req) { - const url = new URL(req.url); +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 500; - // API: Get plan content - if (url.pathname === "/api/plan") { - return Response.json({ plan: planContent }); - } +async function startServer(): Promise> { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return Bun.serve({ + port: configuredPort, - // API: Approve plan - if (url.pathname === "/api/approve" && req.method === "POST") { - resolveDecision({ approved: true }); - return Response.json({ ok: true }); - } + async fetch(req) { + const url = new URL(req.url); + + // API: Get plan content + if (url.pathname === "/api/plan") { + return Response.json({ plan: planContent }); + } + + // API: Detect Obsidian vaults + if (url.pathname === "/api/obsidian/vaults") { + const vaults = detectObsidianVaults(); + return Response.json({ vaults }); + } + + // API: Approve plan + if (url.pathname === "/api/approve" && req.method === "POST") { + // Check for Obsidian integration + try { + const body = (await req.json().catch(() => ({}))) as { + obsidian?: ObsidianConfig; + }; - // API: Deny with feedback - if (url.pathname === "/api/deny" && req.method === "POST") { - try { - const body = await req.json() as { feedback?: string }; - resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" }); - } catch { - resolveDecision({ approved: false, feedback: "Plan rejected by user" }); + if (body.obsidian?.vaultPath && body.obsidian?.plan) { + const result = await saveToObsidian(body.obsidian); + if (result.success) { + console.error(`[Obsidian] Saved plan to: ${result.path}`); + } else { + console.error(`[Obsidian] Save failed: ${result.error}`); + } + } + } catch (err) { + // Don't block approval on Obsidian errors + console.error(`[Obsidian] Error:`, err); + } + + resolveDecision({ approved: true }); + return Response.json({ ok: true }); + } + + // API: Deny with feedback + if (url.pathname === "/api/deny" && req.method === "POST") { + try { + const body = await req.json() as { feedback?: string }; + resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" }); + } catch { + resolveDecision({ approved: false, feedback: "Plan rejected by user" }); + } + return Response.json({ ok: true }); + } + + // Serve embedded HTML for all other routes (SPA) + return new Response(indexHtml, { + headers: { "Content-Type": "text/html" } + }); + }, + }); + } catch (err: unknown) { + const isAddressInUse = err instanceof Error && err.message.includes("EADDRINUSE"); + if (isAddressInUse && attempt < MAX_RETRIES) { + console.error(`Port ${configuredPort} in use, retrying in ${RETRY_DELAY_MS}ms... (${attempt}/${MAX_RETRIES})`); + await Bun.sleep(RETRY_DELAY_MS); + continue; } - return Response.json({ ok: true }); + if (isAddressInUse) { + console.error(`\nError: Port ${configuredPort} is already in use after ${MAX_RETRIES} retries.`); + if (isRemote) { + console.error(`Another Plannotator session may be running.`); + console.error(`To use a different port, set PLANNOTATOR_PORT environment variable.\n`); + } + process.exit(1); + } + throw err; } + } + throw new Error("Unreachable"); +} - // Serve embedded HTML for all other routes (SPA) - return new Response(indexHtml, { - headers: { "Content-Type": "text/html" } - }); - }, -}); +const server = await startServer(); -// Log to stderr so it doesn't interfere with hook stdout -console.error(`Plannotator server running on http://localhost:${server.port}`); +// --- Conditional browser opening and messaging --- -// Open browser -try { - await $`open http://localhost:${server.port}`.quiet(); -} catch { - // Fallback for non-macOS - console.error(`Open browser manually: http://localhost:${server.port}`); +const serverUrl = `http://localhost:${server.port}`; +console.error(`\nPlannotator server running on ${serverUrl}`); + +if (isRemote) { + // SSH session: print helpful setup instructions + console.error(`\n[SSH Remote Session Detected]`); + console.error(`Add this to your local ~/.ssh/config to access Plannotator:\n`); + console.error(` Host your-server-alias`); + console.error(` LocalForward ${server.port} localhost:${server.port}\n`); + console.error(`Then open ${serverUrl} in your local browser.\n`); +} else { + // Local session: try to open browser (cross-platform) + try { + const platform = process.platform; + if (platform === "win32") { + await $`cmd /c start ${serverUrl}`.quiet(); + } else if (platform === "darwin") { + await $`open ${serverUrl}`.quiet(); + } else { + await $`xdg-open ${serverUrl}`.quiet(); + } + } catch { + console.error(`Open browser manually: ${serverUrl}`); + } } // Wait for user decision (blocks until approve/deny) diff --git a/apps/marketing/public/youtube-opencode.png b/apps/marketing/public/youtube-opencode.png new file mode 100644 index 0000000..50fa004 Binary files /dev/null and b/apps/marketing/public/youtube-opencode.png differ diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 0000000..d40a40f --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,308 @@ +# Plannotator MCP Server + +Interactive plan review for AI coding agents via the **Model Context Protocol (MCP)**. + +## Compatible Tools + +Works with any MCP-compatible AI coding agent: + +| Tool | Status | +|------|--------| +| **Cursor** | ✅ Supported | +| **GitHub Copilot** | ✅ Supported | +| **Windsurf** | ✅ Supported | +| **Cline** | ✅ Supported | +| **RooCode** | ✅ Supported | +| **KiloCode** | ✅ Supported | +| **Google Antigravity** | ✅ Supported | +| **Any MCP-compatible tool** | ✅ Supported | + +## Features + +- 🎯 **Visual Plan Review** - View implementation plans in a clean, readable UI +- ✏️ **Rich Annotations** - Delete, insert, replace text, or add comments to specific sections +- ✅ **One-Click Approval** - Approve plans to proceed with implementation +- 🔄 **Structured Feedback** - Request changes with detailed, annotated feedback +- 🔗 **Share Plans** - Share plans with team members via URL + +## Installation + +Add Plannotator to your MCP configuration file. The location and format varies by tool: + +### Cursor + +Add to `.cursor/mcp.json` in your project or `~/.cursor/mcp.json` globally: + +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } +} +``` + +### GitHub Copilot (VS Code) + +Add to your VS Code settings or `.vscode/mcp.json`: + +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } +} +``` + +### Windsurf + +Add to `~/.windsurf/mcp.json`: + +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } +} +``` + +### Cline + +Add to Cline's MCP settings (accessible via Cline settings panel): + +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } +} +``` + +### RooCode / KiloCode + +Add to your tool's MCP configuration: + +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } +} +``` + +### Google Antigravity + +Add to `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (Windows) or `~/.gemini/antigravity/mcp_config.json` (macOS/Linux): + +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } +} +``` + +### Using npx instead of bunx + +If you prefer Node.js over Bun: + +```json +{ + "mcpServers": { + "plannotator": { + "command": "npx", + "args": ["-y", "@plannotator/mcp", "--mcp"] + } + } +} +``` + +After adding the config, restart your tool. The `submit_plan` tool will be available to the agent. + +## How It Works + +1. **Agent Creates Plan** - When in planning mode, the AI agent generates an implementation plan +2. **Submit for Review** - The agent calls the `submit_plan` tool +3. **Plannotator Opens** - A browser window opens with the visual plan review UI +4. **You Review & Annotate** - You can: + - Read through the plan + - Mark sections for deletion (strikethrough) + - Insert new content + - Replace text with alternatives + - Add comments to specific sections +5. **Approve or Request Changes** + - **Approve** → Agent receives approval and proceeds with implementation + - **Request Changes** → Annotated feedback is sent back to the agent +6. **Iterate** - If changes are requested, the agent revises and resubmits + +## The `submit_plan` Tool + +When the MCP server is configured, your AI agent gains access to this tool: + +``` +Tool: submit_plan + +Description: Submit your completed implementation plan for interactive user review. +The user can annotate, approve, or request changes. + +Parameters: + - plan (required): The complete implementation plan in markdown format + - summary (optional): A brief 1-2 sentence summary of what the plan accomplishes +``` + +### Example Agent Usage + +The agent would use it like this: + +``` +I've completed my implementation plan. Let me submit it for your review. + +[calls submit_plan with plan content] +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PLANNOTATOR_PORT` | Fixed port for the review UI | Random | + +## Requirements + +- **Bun** (recommended) or **Node.js** (for running the MCP server) +- An MCP-compatible AI coding agent + +## Running as a Standalone Server + +For testing or debugging, you can run the MCP server directly: + +```bash +# With Bun +bunx @plannotator/mcp --mcp + +# Or install globally and run +npm install -g @plannotator/mcp +plannotator-mcp --mcp +``` + +## Troubleshooting + +### "Tool not found" in your AI tool + +1. Make sure the MCP config file is in the correct location for your tool +2. Restart the AI tool after editing the config +3. Check that Bun or Node.js is installed and in your PATH + +### Browser doesn't open automatically + +The server URL will be printed to stderr. Open it manually: +``` +[Plannotator] Review your plan at: http://localhost:12345 +``` + +### Port already in use + +Set a different port via environment variable: +```json +{ + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"], + "env": { + "PLANNOTATOR_PORT": "19433" + } + } + } +} +``` + +### MCP connection issues + +Ensure Bun is installed: +```bash +# Install Bun +curl -fsSL https://bun.sh/install | bash + +# Or use npm with Node.js +npm install -g @plannotator/mcp +``` + +## Best Practices + +### Plan Format + +For best results, structure your plans with: + +```markdown +# Implementation Plan: Feature Name + +## Overview +High-level description of the approach. + +## Phase 1: Setup +- [ ] Task 1 +- [ ] Task 2 + +## Phase 2: Implementation +### Step 2.1: Create Component +- Details... + +### Step 2.2: Add Styling +- Details... + +## Phase 3: Testing +- Unit test approach +- Integration test plan + +## Potential Challenges +1. Challenge 1 and mitigation +2. Challenge 2 and mitigation +``` + +## Development + +```bash +# Clone the repository +git clone https://github.com/backnotprop/plannotator.git +cd plannotator + +# Install dependencies +bun install + +# Build the UI first +bun run build:hook + +# Build the MCP plugin +bun run build:mcp + +# Test the MCP server +bun run apps/mcp/index.ts --mcp +``` + +## License + +Copyright (c) 2025 backnotprop. +This project is licensed under the Business Source License 1.1 (BSL). + +## Links + +- [Plannotator Website](https://plannotator.ai) +- [GitHub Repository](https://github.com/backnotprop/plannotator) +- [MCP Documentation](https://modelcontextprotocol.io) diff --git a/apps/mcp/index.ts b/apps/mcp/index.ts new file mode 100644 index 0000000..ff1e17d --- /dev/null +++ b/apps/mcp/index.ts @@ -0,0 +1,375 @@ +/** + * Plannotator MCP Server + * + * A Model Context Protocol (MCP) server for interactive plan review. + * Works with any MCP-compatible AI coding agent including: + * - Cursor + * - GitHub Copilot + * - Windsurf + * - Cline + * - RooCode + * - KiloCode + * - Google Antigravity + * - And more... + * + * When the agent calls submit_plan, the Plannotator UI opens for the user to + * annotate, approve, or request changes to the plan. + * + * Usage: + * Add to your MCP configuration and run as MCP server: + * bunx @plannotator/mcp --mcp + * + * @packageDocumentation + */ + +import { $ } from "bun"; + +// @ts-ignore - Bun import attribute for text +import indexHtml from "./plannotator.html" with { type: "text" }; +const htmlContent = indexHtml as unknown as string; + +// --- Types --- + +interface ServerResult { + port: number; + url: string; + waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>; + stop: () => void; +} + +// --- Plannotator Server --- + +/** + * Start a Plannotator server instance to review a plan + */ +async function startPlannotatorServer(planContent: string): Promise { + let resolveDecision: (result: { approved: boolean; feedback?: string }) => void; + const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>( + (resolve) => { resolveDecision = resolve; } + ); + + // Use configured port or random + const port = process.env.PLANNOTATOR_PORT ? parseInt(process.env.PLANNOTATOR_PORT, 10) : 0; + + const server = Bun.serve({ + port, + async fetch(req: Request) { + const url = new URL(req.url); + + // API: Get plan content + if (url.pathname === "/api/plan") { + return Response.json({ plan: planContent }); + } + + // API: Approve plan + if (url.pathname === "/api/approve" && req.method === "POST") { + resolveDecision({ approved: true }); + return Response.json({ ok: true }); + } + + // API: Deny with feedback + if (url.pathname === "/api/deny" && req.method === "POST") { + try { + const body = await req.json() as { feedback?: string }; + resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" }); + } catch { + resolveDecision({ approved: false, feedback: "Plan rejected by user" }); + } + return Response.json({ ok: true }); + } + + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" } + }); + }, + }); + + return { + port: server.port!, + url: `http://localhost:${server.port}`, + waitForDecision: () => decisionPromise, + stop: () => server.stop(), + }; +} + +/** + * Open a URL in the default browser (cross-platform) + */ +async function openBrowser(url: string): Promise { + try { + if (process.platform === "win32") { + await $`cmd /c start ${url}`.quiet(); + } else if (process.platform === "darwin") { + await $`open ${url}`.quiet(); + } else { + await $`xdg-open ${url}`.quiet(); + } + } catch { + // Silently fail - user can open manually if needed + console.error(`[Plannotator] Open browser manually: ${url}`); + } +} + +// --- MCP Tool Definition --- + +/** + * MCP Tool Definition for the Model Context Protocol + */ +const mcpToolDefinition = { + name: "submit_plan", + description: `Submit your completed implementation plan for interactive user review. + +The user will be able to: +- Review your plan visually in a dedicated UI +- Annotate specific sections with feedback (delete, insert, replace, comment) +- Approve the plan to proceed with implementation +- Request changes with detailed feedback + +Call this when you have finished creating your implementation plan. +Do NOT proceed with implementation until your plan is approved.`, + inputSchema: { + type: "object" as const, + properties: { + plan: { + type: "string", + description: "The complete implementation plan in markdown format", + }, + summary: { + type: "string", + description: "A brief 1-2 sentence summary of what the plan accomplishes", + }, + }, + required: ["plan"], + }, +}; + +/** + * Handle the submit_plan tool call + */ +async function handleSubmitPlan(args: { plan: string; summary?: string }): Promise<{ + content: Array<{ type: "text"; text: string }>; +}> { + // Add summary header if provided + let fullPlan = args.plan; + if (args.summary) { + fullPlan = `> **Summary:** ${args.summary}\n\n${args.plan}`; + } + + // Start the Plannotator server + const server = await startPlannotatorServer(fullPlan); + console.error(`\n[Plannotator] Review your plan at: ${server.url}\n`); + + // Open browser automatically + await openBrowser(server.url); + + // Wait for user decision + const result = await server.waitForDecision(); + + // Give browser time to receive response + await Bun.sleep(1500); + + // Cleanup + server.stop(); + + if (result.approved) { + return { + content: [ + { + type: "text", + text: `✅ Plan approved! + +Your plan has been approved by the user. You may now proceed with implementation. + +${args.summary ? `**Plan Summary:** ${args.summary}` : ""}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `⚠️ Plan needs revision. + +The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly. + +## User Feedback + +${result.feedback} + +--- + +Please revise your plan based on this feedback and call \`submit_plan\` again when ready.`, + }, + ], + }; + } +} + +// --- MCP Server Implementation --- + +/** + * Run as MCP server for any MCP-compatible AI coding agent + * + * Usage: bunx @plannotator/mcp --mcp + */ +async function runMcpServer() { + console.error("[Plannotator] Starting MCP server..."); + + // Simple stdio-based MCP server + const stdin = Bun.stdin.stream(); + const reader = stdin.getReader(); + const decoder = new TextDecoder(); + + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete JSON-RPC messages (newline-delimited) + while (buffer.includes("\n")) { + const newlineIndex = buffer.indexOf("\n"); + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (!line) continue; + + try { + const message = JSON.parse(line); + await handleMcpMessage(message); + } catch (e) { + console.error("[Plannotator] Failed to parse message:", e); + } + } + } +} + +async function handleMcpMessage(message: { + jsonrpc: string; + id?: number | string; + method: string; + params?: Record; +}) { + const { id, method, params } = message; + + // Send JSON-RPC response to stdout + function respond(result: unknown) { + const response = JSON.stringify({ jsonrpc: "2.0", id, result }); + console.log(response); + } + + function respondError(code: number, errorMessage: string) { + const response = JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code, message: errorMessage }, + }); + console.log(response); + } + + switch (method) { + case "initialize": + respond({ + protocolVersion: "2024-11-05", + capabilities: { + tools: {}, + }, + serverInfo: { + name: "plannotator", + version: "0.3.0", + }, + }); + break; + + case "tools/list": + respond({ + tools: [mcpToolDefinition], + }); + break; + + case "tools/call": + if ((params as { name?: string })?.name === "submit_plan") { + const args = (params as { arguments?: { plan: string; summary?: string } })?.arguments; + if (!args?.plan) { + respondError(-32602, "Missing required parameter: plan"); + return; + } + const result = await handleSubmitPlan(args); + respond(result); + } else { + respondError(-32601, `Unknown tool: ${(params as { name?: string })?.name}`); + } + break; + + case "notifications/initialized": + // Acknowledgement, no response needed + break; + + default: + if (id !== undefined) { + respondError(-32601, `Method not found: ${method}`); + } + } +} + +// --- Main Entry Point --- + +if (process.argv.includes("--mcp")) { + runMcpServer().catch((err) => { + console.error("[Plannotator] Fatal error:", err); + process.exit(1); + }); +} else { + // Show usage if run without --mcp flag + console.log(` +Plannotator MCP Server + +Interactive plan review for AI coding agents via the Model Context Protocol (MCP). + +Compatible with: + • Cursor + • GitHub Copilot + • Windsurf + • Cline + • RooCode + • KiloCode + • Google Antigravity + • Any MCP-compatible tool + +Usage: + bunx @plannotator/mcp --mcp + +Configuration: + Add to your MCP configuration file (mcp.json, mcp_config.json, etc.): + + { + "mcpServers": { + "plannotator": { + "command": "bunx", + "args": ["@plannotator/mcp", "--mcp"] + } + } + } + + Or with npx: + + { + "mcpServers": { + "plannotator": { + "command": "npx", + "args": ["-y", "@plannotator/mcp", "--mcp"] + } + } + } + +Environment Variables: + PLANNOTATOR_PORT - Fixed port for the review UI (default: random) + +For more information, see: + https://github.com/backnotprop/plannotator +`); +} diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..2f3ee71 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,51 @@ +{ + "name": "@plannotator/mcp", + "version": "0.3.0", + "description": "Plannotator MCP Server - interactive plan review with visual annotation for AI coding agents", + "author": "backnotprop", + "license": "BSL-1.1", + "repository": { + "type": "git", + "url": "git+https://github.com/backnotprop/plannotator.git", + "directory": "apps/mcp" + }, + "homepage": "https://github.com/backnotprop/plannotator", + "bugs": { + "url": "https://github.com/backnotprop/plannotator/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "plannotator", + "plan-review", + "ai-agent", + "coding-agent", + "cursor", + "copilot", + "roocode", + "kilocode", + "windsurf", + "cline", + "antigravity" + ], + "type": "module", + "main": "index.ts", + "bin": { + "plannotator-mcp": "./index.ts" + }, + "files": [ + "index.ts", + "plannotator.html", + "README.md" + ], + "scripts": { + "build": "cp ../hook/dist/index.html ./plannotator.html", + "prepublishOnly": "bun run build" + }, + "peerDependencies": { + "bun": ">=1.0.0" + }, + "devDependencies": { + "bun-types": "^1.0.0" + } +} \ No newline at end of file diff --git a/apps/mcp/plannotator.html b/apps/mcp/plannotator.html new file mode 100644 index 0000000..56529f7 --- /dev/null +++ b/apps/mcp/plannotator.html @@ -0,0 +1,452 @@ + + + + + + Plannotator + + + + + + + + + + +
+ + diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..b0c18a8 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "bun-types" + ], + "lib": [ + "ESNext" + ], + "declaration": true, + "outDir": "./dist" + }, + "include": [ + "*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md new file mode 100644 index 0000000..39c770d --- /dev/null +++ b/apps/opencode-plugin/README.md @@ -0,0 +1,70 @@ +# @plannotator/opencode + +**Annotate plans. Not in the terminal.** + +Interactive Plan Review for OpenCode. Select the exact parts of the plan you want to change—mark for deletion, add a comment, or suggest a replacement. Feedback flows back to your agent automatically. + +Obsidian users can auto-save approved plans to Obsidian as well. [See details](#obsidian-integration) + + + + + +
+Watch Demo

+ +Watch Demo + +
+ +## Install + +Add to your `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@plannotator/opencode"] +} +``` + +Restart OpenCode. The `submit_plan` tool is now available. + +## How It Works + +1. Agent calls `submit_plan` → Plannotator opens in your browser +2. Select text → annotate (delete, replace, comment) +3. **Approve** → Agent proceeds with implementation +4. **Request changes** → Annotations sent back as structured feedback + +## Features + +- **Visual annotations**: Select text, choose an action, see feedback in the sidebar +- **Runs locally**: No network requests. Plans never leave your machine. +- **Private sharing**: Plans and annotations compress into the URL itself—share a link, no accounts or backend required +- **Obsidian integration**: Auto-save approved plans to your vault with frontmatter and tags + +## Obsidian Integration + +Save approved plans directly to your Obsidian vault. + +1. Open Settings in Plannotator UI +2. Enable "Obsidian Integration" and select your vault +3. Approved plans save automatically with: + - Human-readable filenames: `Title - Jan 2, 2026 2-30pm.md` + - YAML frontmatter (`created`, `source`, `tags`) + - Auto-extracted tags from plan title and code languages + - Backlink to `[[Plannotator Plans]]` for graph view + +image + + +## Links + +- [Website](https://plannotator.ai) +- [GitHub](https://github.com/backnotprop/plannotator) +- [Claude Code Plugin](https://github.com/backnotprop/plannotator/tree/main/apps/hook) + +## License + +Copyright (c) 2025 backnotprop. Licensed under [BSL-1.1](https://github.com/backnotprop/plannotator/blob/main/LICENSE). diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts new file mode 100644 index 0000000..82a27ae --- /dev/null +++ b/apps/opencode-plugin/index.ts @@ -0,0 +1,157 @@ +/** + * Plannotator Plugin for OpenCode + * + * Provides a Claude Code-style planning experience with interactive plan review. + * When the agent calls submit_plan, the Plannotator UI opens for the user to + * annotate, approve, or request changes to the plan. + * + * @packageDocumentation + */ + +import { type Plugin, tool } from "@opencode-ai/plugin"; +import { $ } from "bun"; + +// @ts-ignore - Bun import attribute for text +import indexHtml from "./plannotator.html" with { type: "text" }; +const htmlContent = indexHtml as unknown as string; + +interface ServerResult { + port: number; + url: string; + waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>; + stop: () => void; +} + +async function startPlannotatorServer(planContent: string): Promise { + let resolveDecision: (result: { approved: boolean; feedback?: string }) => void; + const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>( + (resolve) => { resolveDecision = resolve; } + ); + + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + + if (url.pathname === "/api/plan") { + return Response.json({ plan: planContent }); + } + + if (url.pathname === "/api/approve" && req.method === "POST") { + resolveDecision({ approved: true }); + return Response.json({ ok: true }); + } + + if (url.pathname === "/api/deny" && req.method === "POST") { + try { + const body = await req.json() as { feedback?: string }; + resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" }); + } catch { + resolveDecision({ approved: false, feedback: "Plan rejected by user" }); + } + return Response.json({ ok: true }); + } + + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" } + }); + }, + }); + + return { + port: server.port!, + url: `http://localhost:${server.port}`, + waitForDecision: () => decisionPromise, + stop: () => server.stop(), + }; +} + +async function openBrowser(url: string): Promise { + try { + if (process.platform === "win32") { + await $`cmd /c start ${url}`.quiet(); + } else if (process.platform === "darwin") { + await $`open ${url}`.quiet(); + } else { + await $`xdg-open ${url}`.quiet(); + } + } catch { + // Silently fail - user can open manually if needed + } +} + +export const PlannotatorPlugin: Plugin = async (ctx) => { + return { + "experimental.chat.system.transform": async (_input, output) => { + output.system.push(` +## Plan Submission + +When you have completed your plan, you MUST call the \`submit_plan\` tool to submit it for user review. +The user will be able to: +- Review your plan visually in a dedicated UI +- Annotate specific sections with feedback +- Approve the plan to proceed with implementation +- Request changes with detailed feedback + +If your plan is rejected, you will receive the user's annotated feedback. Revise your plan +based on their feedback and call submit_plan again. + +Do NOT proceed with implementation until your plan is approved. +`); + }, + + tool: { + submit_plan: tool({ + description: + "Submit your completed plan for interactive user review. The user can annotate, approve, or request changes. Call this when you have finished creating your implementation plan.", + args: { + plan: tool.schema + .string() + .describe("The complete implementation plan in markdown format"), + summary: tool.schema + .string() + .describe("A brief 1-2 sentence summary of what the plan accomplishes"), + }, + + async execute(args, _context) { + const server = await startPlannotatorServer(args.plan); + await openBrowser(server.url); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + if (result.approved) { + try { + await ctx.client.tui.executeCommand({ + body: { command: "agent_cycle" }, + }); + } catch { + // Silently fail - agent switching is optional + } + + return `Plan approved! Switching to build mode. + +Your plan has been approved by the user. You may now proceed with implementation. + +Plan Summary: ${args.summary}`; + } else { + return `Plan needs revision. + +The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly. + +## User Feedback + +${result.feedback} + +--- + +Please revise your plan based on this feedback and call \`submit_plan\` again when ready.`; + } + }, + }), + }, + }; +}; + +export default PlannotatorPlugin; diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json new file mode 100644 index 0000000..bca55db --- /dev/null +++ b/apps/opencode-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "@plannotator/opencode", + "version": "0.3.0", + "description": "Plannotator plugin for OpenCode - interactive plan review with visual annotation", + "author": "backnotprop", + "license": "BSL-1.1", + "repository": { + "type": "git", + "url": "git+https://github.com/backnotprop/plannotator.git", + "directory": "apps/opencode-plugin" + }, + "homepage": "https://github.com/backnotprop/plannotator", + "bugs": { + "url": "https://github.com/backnotprop/plannotator/issues" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "plannotator", + "plan-review", + "ai-agent", + "coding-agent" + ], + "main": "index.ts", + "files": [ + "index.ts", + "plannotator.html", + "README.md" + ], + "scripts": { + "build": "cp ../hook/dist/index.html ./plannotator.html", + "prepublishOnly": "bun run build" + }, + "dependencies": { + "@opencode-ai/plugin": "^1.0.218" + }, + "peerDependencies": { + "bun": ">=1.0.0" + } +} diff --git a/bun.lock b/bun.lock index ab4d932..a9aa070 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "plannotator", }, - "apps/hooks": { + "apps/hook": { "name": "@plannotator/hooks", "version": "0.0.1", "dependencies": { @@ -41,6 +41,18 @@ "vite": "^6.2.0", }, }, + "apps/opencode-plugin": { + "name": "@plannotator/opencode", + "version": "0.0.1", + "dependencies": { + "@opencode-ai/plugin": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.14.0", + "typescript": "~5.8.2", + }, + }, "apps/portal": { "name": "@plannotator/portal", "version": "0.0.1", @@ -66,6 +78,7 @@ "@plannotator/ui": "workspace:*", "react": "^19.2.3", "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", }, }, "packages/ui": { @@ -181,12 +194,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.218", "", { "dependencies": { "@opencode-ai/sdk": "1.0.218", "zod": "4.1.8" } }, "sha512-7fsCx2SIu7TRNn3cdHiaIU7r1/XgnVTBufGnlezH+OZwHP+gILBjqtuMn+1L0e1npnl12CvS3FRNvg/3jr6yhA=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.218", "", {}, "sha512-c6ss6UPAMskSVQUecuhNvPLFngyVh2Os9o0kpVjoqJJ16HXhzjVSk5axgh3ueQrfP5aZfg5o6l6srmjuCTPNnQ=="], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], - "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hooks"], + "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], "@plannotator/marketing": ["@plannotator/marketing@workspace:apps/marketing"], + "@plannotator/opencode": ["@plannotator/opencode@workspace:apps/opencode-plugin"], + "@plannotator/portal": ["@plannotator/portal@workspace:apps/portal"], "@plannotator/ui": ["@plannotator/ui@workspace:packages/ui"], @@ -275,6 +294,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], @@ -287,6 +308,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -405,6 +428,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], diff --git a/package.json b/package.json index 356fa25..7553a9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plannotator", - "version": "0.1.0", + "version": "0.3.0", "private": true, "description": "Interactive Plan Review for Claude Code - annotate plans visually, share with team, automatically send feedback", "author": "backnotprop", @@ -13,13 +13,20 @@ "bugs": { "url": "https://github.com/backnotprop/plannotator/issues" }, - "workspaces": ["apps/*", "packages/*"], + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { "dev:hook": "bun run --cwd apps/hook dev", "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", + "dev:mcp": "bun run --cwd apps/mcp dev", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", - "build:marketing": "bun run --cwd apps/marketing build" + "build:marketing": "bun run --cwd apps/marketing build", + "build:opencode": "bun run --cwd apps/opencode-plugin build", + "build:mcp": "bun run --cwd apps/mcp build", + "build": "bun run build:hook && bun run build:opencode && bun run build:mcp" } -} +} \ No newline at end of file diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 455a002..89b8724 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -13,6 +13,7 @@ import { Settings } from '@plannotator/ui/components/Settings'; import { useSharing } from '@plannotator/ui/hooks/useSharing'; import { storage } from '@plannotator/ui/utils/storage'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; +import { getObsidianSettings } from '@plannotator/ui/utils/obsidian'; const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration @@ -39,8 +40,17 @@ server.on('connection', (socket, request) => { ### Client Connection - Establish persistent connection on document load + - Initialize WebSocket with authentication token + - Set up heartbeat ping/pong every 30 seconds + - Handle connection state changes (connecting, open, closing, closed) - Implement reconnection logic with exponential backoff + - Start with 1 second delay + - Double delay on each retry (max 30 seconds) + - Reset delay on successful connection - Handle offline state gracefully + - Queue local changes in IndexedDB + - Show offline indicator in UI + - Sync queued changes on reconnect ### Database Schema @@ -71,9 +81,20 @@ CREATE INDEX idx_collaborators_document ON collaborators(document_id); Key requirements: - Transform insert against insert + - Same position: use user ID for deterministic ordering + - Different positions: adjust offset of later operation - Transform insert against delete + - Insert before delete: no change needed + - Insert inside deleted range: special handling required + - Option A: Move insert to delete start position + - Option B: Discard the insert entirely + - Insert after delete: adjust insert position - Transform delete against delete + - Non-overlapping: adjust positions + - Overlapping: merge or split operations - Maintain cursor positions across transforms + - Track cursor as a zero-width insert operation + - Update cursor position after each transform ### Transform Implementation @@ -127,9 +148,26 @@ class OperationalTransform { ## Phase 3: UI Updates 1. Show collaborator cursors in real-time + - Render cursor as colored vertical line + - Add name label above cursor + - Animate cursor movement smoothly 2. Display presence indicators + - Avatar stack in header + - Dropdown with full collaborator list + - Show online/away status + - Display last activity time + - Allow @mentioning collaborators 3. Add conflict resolution UI + - Highlight conflicting regions + - Show diff comparison panel + - Provide merge options: + - Accept mine + - Accept theirs + - Manual merge 4. Implement undo/redo stack per user + - Track operations by user ID + - Allow undoing only own changes + - Show undo history in sidebar ### React Component for Cursors @@ -209,6 +247,47 @@ export const CursorOverlay: React.FC = ({ --- +## Pre-launch Checklist + +- [ ] Infrastructure ready + - [x] WebSocket server deployed + - [x] Database migrations applied + - [ ] Load balancer configured + - [ ] SSL certificates installed + - [ ] Health checks enabled + - [ ] /health endpoint returns 200 + - [ ] /ready endpoint checks DB connection + - [ ] Primary database + - [ ] Read replicas + - [ ] us-east-1 replica + - [ ] eu-west-1 replica +- [ ] Security audit complete + - [x] Authentication flow reviewed + - [ ] Rate limiting implemented + - [x] 100 req/min for anonymous users + - [ ] 1000 req/min for authenticated users + - [ ] Input sanitization verified +- [x] Documentation updated + - [x] API reference generated + - [x] Integration guide written + - [ ] Video tutorials recorded + +### Mixed List Styles + +* Asterisk item at level 0 + - Dash item at level 1 + * Asterisk at level 2 + - Dash at level 3 + * Asterisk at level 4 + - Maximum reasonable depth +1. Numbered item + - Sub-bullet under numbered + - Another sub-bullet + 1. Nested numbered list + 2. Second nested number + +--- + **Target:** Ship MVP in next sprint `; @@ -299,7 +378,24 @@ const App: React.FC = () => { const handleApprove = async () => { setIsSubmitting(true); try { - await fetch('/api/approve', { method: 'POST' }); + const obsidianSettings = getObsidianSettings(); + + // Build request body - include obsidian config if enabled + const body = obsidianSettings.enabled && obsidianSettings.vaultPath + ? { + obsidian: { + vaultPath: obsidianSettings.vaultPath, + folder: obsidianSettings.folder || 'plannotator', + plan: markdown, + }, + } + : {}; + + await fetch('/api/approve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); setSubmitted('approved'); } catch { setIsSubmitting(false); @@ -356,7 +452,7 @@ const App: React.FC = () => { > Plannotator - v0.1 + v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'}
diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 67dbcce..13db194 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -168,10 +168,30 @@ const AnnotationCard: React.FC<{ ) + }, + [AnnotationType.GLOBAL_COMMENT]: { + label: 'Global', + color: 'text-purple-500', + bg: 'bg-purple-500/10', + icon: ( + + + + ) } }; - const config = typeConfig[annotation.type]; + // Fallback for unknown types (forward compatibility) + const config = typeConfig[annotation.type] || { + label: 'Note', + color: 'text-muted-foreground', + bg: 'bg-muted/50', + icon: ( + + + + ) + }; return (
- {/* Original Text */} -
- "{annotation.originalText}" -
- - {/* Comment/Replacement Text */} - {annotation.text && annotation.type !== AnnotationType.DELETION && ( -
+ {/* Global Comment - show text directly */} + {annotation.type === AnnotationType.GLOBAL_COMMENT ? ( +
{annotation.text}
+ ) : ( + <> + {/* Original Text */} +
+ "{annotation.originalText}" +
+ + {/* Comment/Replacement Text */} + {annotation.text && annotation.type !== AnnotationType.DELETION && ( +
+ {annotation.text} +
+ )} + )}
); diff --git a/packages/ui/components/Landing.tsx b/packages/ui/components/Landing.tsx index 761e4b3..ca2b38e 100644 --- a/packages/ui/components/Landing.tsx +++ b/packages/ui/components/Landing.tsx @@ -47,7 +47,7 @@ export const Landing: React.FC = ({ onEnter }) => {
- For Claude Code + For Claude Code & OpenCode
@@ -66,71 +66,82 @@ export const Landing: React.FC = ({ onEnter }) => {

- Interactive Plan Review: Mark up and refine your plans using a UI, - easily share for team collaboration, automatically integrates with - agent plan mode. + Interactive Plan Review for coding agents. Mark up and refine plans visually, + share for team collaboration. Works with Claude Code and OpenCode.

-
- {/* YouTube button */} - - - - - See it in action - - - {/* Open Demo button */} - {onEnter ? ( - - ) : ( +
+
+ {/* YouTube button - Claude Code */} - Open Demo - + + Watch for Claude Code - )} + + {/* Open Demo button */} + {onEnter ? ( + + ) : ( + + Open Demo + + + + + )} +
+ + {/* OpenCode video link */} + + Also watch for OpenCode → +
@@ -143,10 +154,9 @@ export const Landing: React.FC = ({ onEnter }) => { The Problem

- Claude Code shows plans in the terminal. You read them, approve - or deny, but giving specific feedback means typing everything - out. Hard to reference exact sections. Zero team collaboration - features. + Coding agents show plans in the terminal. You approve or deny, but + giving specific feedback means typing everything out. Hard to + reference exact sections. Zero collaboration features.

@@ -154,10 +164,9 @@ export const Landing: React.FC = ({ onEnter }) => { The Solution

- Select the exact parts of the plan you want to change. Mark it - for deletion, add a comment, or suggest a replacement. Share - plans and collect team member feedback. Automatically send - feedback for Claude Code to act on. + Select the exact parts of the plan you want to change. Mark for + deletion, add a comment, or suggest a replacement. Share plans + with your team. Feedback flows back to your agent automatically.

@@ -224,6 +233,31 @@ export const Landing: React.FC = ({ onEnter }) => {

+
+
+ + + +
+
+

Save to Obsidian.

+

+ Approved plans auto-save to your vault with frontmatter and + auto-extracted tags. Build a searchable archive of every plan + your agents create. +

+
+
@@ -344,20 +378,21 @@ export const Landing: React.FC = ({ onEnter }) => {

How it works

- - - ExitPlanMode → opens Plannotator with plan content - + + + Claude Code: ExitPlanMode hook opens UI
+ OpenCode: Agent calls submit_plan tool +
- Select text → choose action (delete, comment) → annotations appear + Select text → choose action (delete, comment, replace) → annotations appear in the sidebar - Click approve to proceed, or deny with annotations. Feedback flows - back to Claude automatically via hooks. + Click approve to proceed, or provide feedback with annotations. + Feedback flows back to your agent automatically.
@@ -372,7 +407,11 @@ export const Landing: React.FC = ({ onEnter }) => {
  • - Claude Code plugin with PermissionRequest hook on ExitPlanMode + Claude Code: Binary + plugin with PermissionRequest hook +
  • +
  • + + OpenCode: npm package with submit_plan tool
  • diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 0fe2692..c3185fd 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -2,6 +2,11 @@ import React, { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { TaterSpritePullup } from './TaterSpritePullup'; import { getIdentity, regenerateIdentity } from '../utils/identity'; +import { + getObsidianSettings, + saveObsidianSettings, + type ObsidianSettings, +} from '../utils/obsidian'; interface SettingsProps { taterMode: boolean; @@ -12,13 +17,45 @@ interface SettingsProps { export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange }) => { const [showDialog, setShowDialog] = useState(false); const [identity, setIdentity] = useState(''); + const [obsidian, setObsidian] = useState({ + enabled: false, + vaultPath: '', + folder: 'plannotator', + }); + const [detectedVaults, setDetectedVaults] = useState([]); + const [vaultsLoading, setVaultsLoading] = useState(false); useEffect(() => { if (showDialog) { setIdentity(getIdentity()); + setObsidian(getObsidianSettings()); } }, [showDialog]); + // Fetch detected vaults when Obsidian is enabled + useEffect(() => { + if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) { + setVaultsLoading(true); + fetch('/api/obsidian/vaults') + .then(res => res.json()) + .then((data: { vaults: string[] }) => { + setDetectedVaults(data.vaults || []); + // Auto-select first vault if none set + if (data.vaults?.length > 0 && !obsidian.vaultPath) { + handleObsidianChange({ vaultPath: data.vaults[0] }); + } + }) + .catch(() => setDetectedVaults([])) + .finally(() => setVaultsLoading(false)); + } + }, [obsidian.enabled]); + + const handleObsidianChange = (updates: Partial) => { + const newSettings = { ...obsidian, ...updates }; + setObsidian(newSettings); + saveObsidianSettings(newSettings); + }; + const handleRegenerateIdentity = () => { const oldIdentity = identity; const newIdentity = regenerateIdentity(); @@ -104,6 +141,100 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange /> + +
    + + {/* Obsidian Integration */} +
    +
    +
    +
    Obsidian Integration
    +
    + Auto-save approved plans to your vault +
    +
    + +
    + + {obsidian.enabled && ( +
    + {/* Vault Path */} +
    + + {vaultsLoading ? ( +
    + Detecting vaults... +
    + ) : detectedVaults.length > 0 ? ( + + ) : ( + handleObsidianChange({ vaultPath: e.target.value })} + placeholder="/Users/you/Documents/MyVault" + className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + )} + {detectedVaults.length === 0 && !vaultsLoading && ( +
    + No vaults detected. Enter path manually. +
    + )} +
    + + {/* Folder */} +
    + + handleObsidianChange({ folder: e.target.value })} + placeholder="plannotator" + className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
    + Plans saved to: {obsidian.vaultPath || '...'}/{obsidian.folder || 'plannotator'}/ +
    +
    + + {/* Frontmatter Preview */} +
    + +
    +{`---
    +created: ${new Date().toISOString().slice(0, 19)}Z
    +source: plannotator
    +tags: [plan, ...]
    +---`}
    +                      
    +
    +
    + )} +
    , diff --git a/packages/ui/components/Toolbar.tsx b/packages/ui/components/Toolbar.tsx index 79fbe57..d2010ae 100644 --- a/packages/ui/components/Toolbar.tsx +++ b/packages/ui/components/Toolbar.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { AnnotationType } from '../types'; -import { createPortal } from 'react-dom'; +import React, { useState, useEffect, useRef } from "react"; +import { AnnotationType } from "../types"; +import { createPortal } from "react-dom"; interface ToolbarProps { highlightElement: HTMLElement | null; @@ -8,21 +8,28 @@ interface ToolbarProps { onClose: () => void; } -export const Toolbar: React.FC = ({ highlightElement, onAnnotate, onClose }) => { - const [step, setStep] = useState<'menu' | 'input'>('menu'); +export const Toolbar: React.FC = ({ + highlightElement, + onAnnotate, + onClose, +}) => { + const [step, setStep] = useState<"menu" | "input">("menu"); const [activeType, setActiveType] = useState(null); - const [inputValue, setInputValue] = useState(''); - const [position, setPosition] = useState<{ top: number; left: number } | null>(null); - const inputRef = useRef(null); + const [inputValue, setInputValue] = useState(""); + const [position, setPosition] = useState<{ + top: number; + left: number; + } | null>(null); + const inputRef = useRef(null); useEffect(() => { - if (step === 'input') inputRef.current?.focus(); + if (step === "input") inputRef.current?.focus(); }, [step]); useEffect(() => { - setStep('menu'); + setStep("menu"); setActiveType(null); - setInputValue(''); + setInputValue(""); }, [highlightElement]); // Update position on scroll/resize @@ -36,8 +43,12 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate, const rect = highlightElement.getBoundingClientRect(); const toolbarTop = rect.top - 48; - // If selection scrolled out of viewport, close the toolbar - if (rect.bottom < 0 || rect.top > window.innerHeight) { + // If selection scrolled out of viewport, only close if still in menu step + // Don't close when user is typing - they can scroll back to continue + if ( + step === "menu" && + (rect.bottom < 0 || rect.top > window.innerHeight) + ) { onClose(); return; } @@ -49,14 +60,14 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate, }; updatePosition(); - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); }; - }, [highlightElement, onClose]); + }, [highlightElement, onClose, step]); if (!highlightElement || !position) return null; @@ -67,7 +78,7 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate, onAnnotate(type); } else { setActiveType(type); - setStep('input'); + setStep("input"); } }; @@ -82,15 +93,25 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
    e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} > - {step === 'menu' ? ( + {step === "menu" ? (
    handleTypeSelect(AnnotationType.DELETION)} icon={ - - + + } label="Delete" @@ -99,8 +120,18 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate, handleTypeSelect(AnnotationType.COMMENT)} icon={ - - + + } label="Comment" @@ -110,8 +141,18 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate, - + + } label="Cancel" @@ -119,30 +160,55 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate, />
    ) : ( -
    - +