plan-annotator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Plan Annotator</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap" rel="stylesheet" />
11
+ <script type="module" crossorigin src="/assets/index-QhnyzoVj.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BpByFhhl.css">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ </body>
17
+ </html>
package/dist/vite.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "plan-annotator",
3
+ "version": "0.1.0",
4
+ "description": "Visual plan review UI for Claude Code — annotate AI-generated plans in the browser",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "kangju2000",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kangju2000/plan-annotator.git"
11
+ },
12
+ "keywords": [
13
+ "claude-code",
14
+ "plan-review",
15
+ "annotation",
16
+ "ai",
17
+ "hook"
18
+ ],
19
+ "bin": {
20
+ "plan-annotator": "bin/plan-annotator.mjs"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "dist/",
25
+ "server/"
26
+ ],
27
+ "scripts": {
28
+ "dev": "vite",
29
+ "build": "vite build",
30
+ "lint": "eslint .",
31
+ "preview": "vite preview",
32
+ "server": "tsx server/index.ts",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "dependencies": {
36
+ "tsx": "^4.19.0"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^9.39.1",
40
+ "@tailwindcss/vite": "^4.2.1",
41
+ "@types/node": "^24.10.1",
42
+ "@types/react": "^19.2.7",
43
+ "@types/react-dom": "^19.2.3",
44
+ "@vitejs/plugin-react": "^5.1.1",
45
+ "eslint": "^9.39.1",
46
+ "eslint-plugin-react-hooks": "^7.0.1",
47
+ "eslint-plugin-react-refresh": "^0.4.24",
48
+ "globals": "^16.5.0",
49
+ "react": "^19.2.0",
50
+ "react-dom": "^19.2.0",
51
+ "shiki": "^4.0.1",
52
+ "tailwindcss": "^4.2.1",
53
+ "typescript": "~5.9.3",
54
+ "typescript-eslint": "^8.48.0",
55
+ "vite": "^7.3.1"
56
+ }
57
+ }
@@ -0,0 +1,388 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execSync } from 'node:child_process';
5
+
6
+ // ── Constants ──────────────────────────────────────────────────────────
7
+
8
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
9
+ const HEARTBEAT_INTERVAL_MS = 3000;
10
+ const HEARTBEAT_MISS_LIMIT = 3; // 9 seconds without heartbeat → assume tab closed
11
+ const DIST_DIR = path.resolve(import.meta.dirname, '..', 'dist');
12
+ const FIXED_PORT = process.env.PLAN_ANNOTATOR_PORT
13
+ ? parseInt(process.env.PLAN_ANNOTATOR_PORT, 10)
14
+ : 0; // 0 = random
15
+
16
+ const MIME_TYPES: Record<string, string> = {
17
+ '.html': 'text/html; charset=utf-8',
18
+ '.js': 'application/javascript; charset=utf-8',
19
+ '.css': 'text/css; charset=utf-8',
20
+ '.json': 'application/json; charset=utf-8',
21
+ '.svg': 'image/svg+xml',
22
+ '.png': 'image/png',
23
+ '.ico': 'image/x-icon',
24
+ '.woff': 'font/woff',
25
+ '.woff2': 'font/woff2',
26
+ '.map': 'application/json',
27
+ };
28
+
29
+ // ── Logging (stderr only — stdout reserved for hook response) ──────────
30
+
31
+ function log(...args: unknown[]) {
32
+ process.stderr.write(`[plan-annotator] ${args.join(' ')}\n`);
33
+ }
34
+
35
+ // ── Read stdin (hook event JSON) ───────────────────────────────────────
36
+
37
+ function readStdin(): Promise<string> {
38
+ return new Promise((resolve, reject) => {
39
+ const chunks: Buffer[] = [];
40
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
41
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
42
+ process.stdin.on('error', reject);
43
+
44
+ // If stdin is a TTY (manual testing), resolve after a short delay
45
+ if (process.stdin.isTTY) {
46
+ log('stdin is TTY — using sample plan for testing');
47
+ resolve('');
48
+ }
49
+ });
50
+ }
51
+
52
+ interface HookEvent {
53
+ hook_event_name: string;
54
+ tool_name: string;
55
+ tool_input: {
56
+ description?: string;
57
+ command?: string;
58
+ [key: string]: unknown;
59
+ };
60
+ }
61
+
62
+ function extractPlanMarkdown(raw: string): string {
63
+ if (!raw.trim()) {
64
+ // Fallback for manual testing
65
+ return '# Sample Plan\n\nThis is a test plan for local development.\n\n## Step 1\n\nDo something.\n\n## Step 2\n\nDo something else.';
66
+ }
67
+
68
+ try {
69
+ const event: HookEvent = JSON.parse(raw);
70
+ // Extract plan from tool_input.description (Task tool)
71
+ // or tool_input.command or stringify the whole input
72
+ const plan =
73
+ event.tool_input?.description ??
74
+ event.tool_input?.command ??
75
+ JSON.stringify(event.tool_input, null, 2);
76
+ return plan;
77
+ } catch {
78
+ // If it's not JSON, treat the whole input as markdown
79
+ return raw;
80
+ }
81
+ }
82
+
83
+ // ── CORS headers ───────────────────────────────────────────────────────
84
+
85
+ function setCorsHeaders(res: http.ServerResponse) {
86
+ res.setHeader('Access-Control-Allow-Origin', '*');
87
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
88
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
89
+ }
90
+
91
+ // ── Static file serving ────────────────────────────────────────────────
92
+
93
+ function serveStaticFile(res: http.ServerResponse, urlPath: string): boolean {
94
+ // Normalize: / → /index.html
95
+ let filePath = urlPath === '/' ? '/index.html' : urlPath;
96
+
97
+ // Security: prevent directory traversal
98
+ const resolved = path.resolve(DIST_DIR, '.' + filePath);
99
+ if (!resolved.startsWith(DIST_DIR)) {
100
+ res.writeHead(403);
101
+ res.end('Forbidden');
102
+ return true;
103
+ }
104
+
105
+ if (!fs.existsSync(resolved)) {
106
+ return false;
107
+ }
108
+
109
+ const ext = path.extname(resolved);
110
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
111
+
112
+ const content = fs.readFileSync(resolved);
113
+ res.writeHead(200, { 'Content-Type': contentType });
114
+ res.end(content);
115
+ return true;
116
+ }
117
+
118
+ // ── Read POST body ─────────────────────────────────────────────────────
119
+
120
+ function readBody(req: http.IncomingMessage, maxBytes = 1_048_576): Promise<string> {
121
+ return new Promise((resolve, reject) => {
122
+ const chunks: Buffer[] = [];
123
+ let totalBytes = 0;
124
+ req.on('data', (chunk: Buffer) => {
125
+ totalBytes += chunk.length;
126
+ if (totalBytes > maxBytes) {
127
+ req.destroy();
128
+ reject(new Error('Request body too large'));
129
+ return;
130
+ }
131
+ chunks.push(chunk);
132
+ });
133
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
134
+ req.on('error', reject);
135
+ });
136
+ }
137
+
138
+ // ── Format hook response ───────────────────────────────────────────────
139
+
140
+ interface Decision {
141
+ decision: 'approve' | 'request_changes';
142
+ annotations: Array<{
143
+ type: string;
144
+ blockId: string;
145
+ content: string;
146
+ selectedText?: string;
147
+ suggestion?: string;
148
+ }>;
149
+ blockStatuses: Record<string, string>;
150
+ }
151
+
152
+ function formatHookResponse(decision: Decision): string {
153
+ if (decision.decision === 'approve') {
154
+ const annotationSummary =
155
+ decision.annotations.length > 0
156
+ ? decision.annotations
157
+ .map((a) => `[${a.type}] ${a.content}`)
158
+ .join('; ')
159
+ : 'No annotations';
160
+
161
+ return JSON.stringify({
162
+ decision: 'approve',
163
+ reason: `Plan approved with annotations: ${annotationSummary}`,
164
+ });
165
+ }
166
+
167
+ // request_changes → block
168
+ const changeDetails = decision.annotations
169
+ .map((a) => {
170
+ let detail = `- [${a.type}] ${a.content}`;
171
+ if (a.selectedText) detail += ` (on: "${a.selectedText}")`;
172
+ if (a.suggestion) detail += ` → "${a.suggestion}"`;
173
+ return detail;
174
+ })
175
+ .join('\n');
176
+
177
+ return JSON.stringify({
178
+ decision: 'block',
179
+ reason: `Changes requested:\n${changeDetails}`,
180
+ });
181
+ }
182
+
183
+ // ── Open browser ───────────────────────────────────────────────────────
184
+
185
+ function copyToClipboard(text: string) {
186
+ try {
187
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
188
+ log('URL copied to clipboard');
189
+ } catch {
190
+ // silently fail on non-macOS
191
+ }
192
+ }
193
+
194
+ function openBrowser(url: string) {
195
+ try {
196
+ // macOS
197
+ execSync(`open "${url}"`, { stdio: 'ignore' });
198
+ log(`Opened browser: ${url}`);
199
+ } catch {
200
+ log(`Could not open browser automatically. Please visit: ${url}`);
201
+ }
202
+ }
203
+
204
+ // ── Main ───────────────────────────────────────────────────────────────
205
+
206
+ async function main() {
207
+ // 1. Read plan from stdin
208
+ log('Reading hook event from stdin...');
209
+ const raw = await readStdin();
210
+ const planMarkdown = extractPlanMarkdown(raw);
211
+ log(`Plan extracted (${planMarkdown.length} chars)`);
212
+
213
+ // 2. Verify dist directory exists
214
+ if (!fs.existsSync(DIST_DIR)) {
215
+ log(`ERROR: dist/ directory not found at ${DIST_DIR}`);
216
+ log('Run "npm run build" first to build the frontend.');
217
+ process.exit(1);
218
+ }
219
+
220
+ // 3. Create HTTP server
221
+ let resolveDecision: (value: Decision) => void;
222
+ const decisionPromise = new Promise<Decision>((resolve) => {
223
+ resolveDecision = resolve;
224
+ });
225
+
226
+ // Heartbeat tracking — detect browser tab close
227
+ let lastHeartbeat = Date.now();
228
+ let heartbeatConnected = false;
229
+ const startTime = Date.now();
230
+ const sseClients = new Set<http.ServerResponse>();
231
+
232
+ const server = http.createServer(async (req, res) => {
233
+ setCorsHeaders(res);
234
+
235
+ // Handle CORS preflight
236
+ if (req.method === 'OPTIONS') {
237
+ res.writeHead(204);
238
+ res.end();
239
+ return;
240
+ }
241
+
242
+ const url = new URL(req.url || '/', `http://localhost`);
243
+
244
+ // API: GET /api/plan
245
+ if (req.method === 'GET' && url.pathname === '/api/plan') {
246
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
247
+ res.end(JSON.stringify({ markdown: planMarkdown }));
248
+ return;
249
+ }
250
+
251
+ // API: GET /api/heartbeat
252
+ if (req.method === 'GET' && url.pathname === '/api/heartbeat') {
253
+ lastHeartbeat = Date.now();
254
+ heartbeatConnected = true;
255
+ res.writeHead(200, { 'Content-Type': 'application/json' });
256
+ res.end(JSON.stringify({ ok: true }));
257
+ return;
258
+ }
259
+
260
+ // API: GET /api/status (SSE — push countdown + connection state)
261
+ if (req.method === 'GET' && url.pathname === '/api/status') {
262
+ res.writeHead(200, {
263
+ 'Content-Type': 'text/event-stream',
264
+ 'Cache-Control': 'no-cache',
265
+ 'Connection': 'keep-alive',
266
+ 'Access-Control-Allow-Origin': '*',
267
+ });
268
+ res.write(':ok\n\n');
269
+ sseClients.add(res);
270
+ req.on('close', () => {
271
+ sseClients.delete(res);
272
+ });
273
+ return;
274
+ }
275
+
276
+ // API: POST /api/decision
277
+ if (req.method === 'POST' && url.pathname === '/api/decision') {
278
+ try {
279
+ const body = await readBody(req);
280
+ const decision: Decision = JSON.parse(body);
281
+ log(`Decision received: ${decision.decision}`);
282
+ res.writeHead(200, { 'Content-Type': 'application/json' });
283
+ res.end(JSON.stringify({ ok: true }));
284
+ resolveDecision!(decision);
285
+ } catch (err) {
286
+ log(`Error parsing decision: ${err}`);
287
+ res.writeHead(400, { 'Content-Type': 'application/json' });
288
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
289
+ }
290
+ return;
291
+ }
292
+
293
+ // Static files from dist/
294
+ if (req.method === 'GET') {
295
+ if (serveStaticFile(res, url.pathname)) return;
296
+
297
+ // SPA fallback: serve index.html for unmatched routes
298
+ if (serveStaticFile(res, '/')) return;
299
+ }
300
+
301
+ res.writeHead(404);
302
+ res.end('Not Found');
303
+ });
304
+
305
+ // 4. Start on configured or random port
306
+ await new Promise<void>((resolve) => {
307
+ server.listen(FIXED_PORT, '127.0.0.1', () => resolve());
308
+ });
309
+
310
+ const addr = server.address();
311
+ if (!addr || typeof addr === 'string') {
312
+ log('ERROR: Could not determine server address');
313
+ process.exit(1);
314
+ }
315
+
316
+ const url = `http://127.0.0.1:${addr.port}`;
317
+ log(`Server listening on ${url}`);
318
+ log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
319
+ log(` Plan Annotator: ${url}`);
320
+ log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
321
+
322
+ // 5. Open browser + copy URL to clipboard
323
+ copyToClipboard(url);
324
+ openBrowser(url);
325
+
326
+ // 6. Set up timeout
327
+ const timeout = setTimeout(() => {
328
+ log('Timeout reached (5 minutes). Shutting down.');
329
+ const response = JSON.stringify({
330
+ decision: 'block',
331
+ reason: 'Plan review timed out — no decision was made within 5 minutes.',
332
+ });
333
+ process.stdout.write(response);
334
+ server.close();
335
+ process.exit(1);
336
+ }, TIMEOUT_MS);
337
+
338
+ // 6b. Heartbeat monitor — detect tab close + SSE countdown broadcast
339
+ const heartbeatCheck = setInterval(() => {
340
+ // Broadcast countdown to all SSE clients
341
+ const elapsed = Date.now() - startTime;
342
+ const remainingMs = Math.max(0, TIMEOUT_MS - elapsed);
343
+ const sseData = JSON.stringify({
344
+ remainingMs,
345
+ totalMs: TIMEOUT_MS,
346
+ connected: heartbeatConnected,
347
+ });
348
+ for (const client of sseClients) {
349
+ client.write(`data: ${sseData}\n\n`);
350
+ }
351
+
352
+ if (!heartbeatConnected) return; // wait for first heartbeat
353
+ const heartbeatElapsed = Date.now() - lastHeartbeat;
354
+ if (heartbeatElapsed > HEARTBEAT_INTERVAL_MS * HEARTBEAT_MISS_LIMIT) {
355
+ log('Browser tab closed (heartbeat lost). Shutting down.');
356
+ clearInterval(heartbeatCheck);
357
+ clearTimeout(timeout);
358
+ const response = JSON.stringify({
359
+ decision: 'block',
360
+ reason: 'Plan review cancelled — browser tab was closed.',
361
+ });
362
+ process.stdout.write(response);
363
+ server.close();
364
+ process.exit(1);
365
+ }
366
+ }, HEARTBEAT_INTERVAL_MS);
367
+
368
+ // 7. Wait for decision
369
+ const decision = await decisionPromise;
370
+ clearTimeout(timeout);
371
+ clearInterval(heartbeatCheck);
372
+ for (const client of sseClients) client.end();
373
+ sseClients.clear();
374
+
375
+ // 8. Output hook response to stdout
376
+ const hookResponse = formatHookResponse(decision);
377
+ process.stdout.write(hookResponse);
378
+ log('Hook response sent to stdout. Shutting down.');
379
+
380
+ // 9. Cleanup
381
+ server.close();
382
+ process.exit(0);
383
+ }
384
+
385
+ main().catch((err) => {
386
+ log(`Fatal error: ${err}`);
387
+ process.exit(1);
388
+ });